Compare commits
262 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3c347c854c | ||
![]() |
89fa2980e6 | ||
![]() |
7479ffb134 | ||
![]() |
97884a5293 | ||
![]() |
002a87a6df | ||
![]() |
17f0b7a553 | ||
![]() |
69ddc44796 | ||
![]() |
9e6d6fce73 | ||
![]() |
018ec0dd94 | ||
![]() |
c2d580ee0d | ||
![]() |
188c5bc04b | ||
![]() |
35ae5f9ee7 | ||
![]() |
ebd98d29c1 | ||
![]() |
446b4095f6 | ||
![]() |
2b5b3494f2 | ||
![]() |
6fb5737118 | ||
![]() |
16121ff547 | ||
![]() |
2c0491a5b0 | ||
![]() |
0f6d79683e | ||
![]() |
0b3eb2e87f | ||
![]() |
668edb03d3 | ||
![]() |
ad92de141b | ||
![]() |
bd96f6ca50 | ||
![]() |
9ab6c65d85 | ||
![]() |
378dd8e36d | ||
![]() |
b8af178cbf | ||
![]() |
48e28b9abd | ||
![]() |
89bd921875 | ||
![]() |
e427fbf54c | ||
![]() |
ee0b435493 | ||
![]() |
baac78021a | ||
![]() |
c2e81832a9 | ||
![]() |
58d021dde6 | ||
![]() |
1098a04fb9 | ||
![]() |
76dc3c44c8 | ||
![]() |
2d5cce9fdb | ||
![]() |
12295a6f68 | ||
![]() |
500e138643 | ||
![]() |
04e80b339c | ||
![]() |
750891cffa | ||
![]() |
fac8ef4027 | ||
![]() |
19fb14d553 | ||
![]() |
5c84d90bf1 | ||
![]() |
6767b1dac0 | ||
![]() |
e0ecf34ced | ||
![]() |
396c442062 | ||
![]() |
eaab31aacc | ||
![]() |
0044d265d1 | ||
![]() |
19a910a91c | ||
![]() |
6d8ce5361a | ||
![]() |
d2f99a5ec0 | ||
![]() |
c985fc17bf | ||
![]() |
73cf66c592 | ||
![]() |
ee044ed2ff | ||
![]() |
9dd3bd1f53 | ||
![]() |
55a064c2a4 | ||
![]() |
c8436aaf03 | ||
![]() |
edc01a341c | ||
![]() |
531ede0adf | ||
![]() |
a536ad49ea | ||
![]() |
b08181e712 | ||
![]() |
bc077b658d | ||
![]() |
48b91581b8 | ||
![]() |
d1d32cdbe6 | ||
![]() |
2b25a67bbf | ||
![]() |
64f1f28982 | ||
![]() |
f49ab6fd0d | ||
![]() |
068c8ab2e7 | ||
![]() |
2ca90a18e1 | ||
![]() |
fcf2b87d1c | ||
![]() |
d5610ad6be | ||
![]() |
ec5f50aba4 | ||
![]() |
a02814aa02 | ||
![]() |
e15c2a2f07 | ||
![]() |
f1f7b698f8 | ||
![]() |
dfdd49cf4a | ||
![]() |
d110d9b732 | ||
![]() |
882f011d07 | ||
![]() |
8941f8f2f4 | ||
![]() |
089ace562a | ||
![]() |
f963c1980b | ||
![]() |
1ff2c7afd9 | ||
![]() |
7a8808df4f | ||
![]() |
4c1c0087c7 | ||
![]() |
fd7d8e65c8 | ||
![]() |
55b70eebbd | ||
![]() |
d13b890e16 | ||
![]() |
c2e9f82cd6 | ||
![]() |
e0f6034868 | ||
![]() |
e13f6f2612 | ||
![]() |
85a65aef52 | ||
![]() |
5cf7708ab8 | ||
![]() |
a549149452 | ||
![]() |
e2285e2deb | ||
![]() |
426766225b | ||
![]() |
1220c56fc5 | ||
![]() |
7eb8ec228a | ||
![]() |
cb2326bb04 | ||
![]() |
51a0da8f10 | ||
![]() |
07cd725d4a | ||
![]() |
d86ebe3e58 | ||
![]() |
91e99e1bcc | ||
![]() |
b6b0857f17 | ||
![]() |
9f01d9cb12 | ||
![]() |
b4eb35c591 | ||
![]() |
7e66f6b49f | ||
![]() |
b848cfd921 | ||
![]() |
6281994be8 | ||
![]() |
d94a6cea5a | ||
![]() |
0d36c5cf94 | ||
![]() |
22471d64c7 | ||
![]() |
e3f167921c | ||
![]() |
d1c61bb393 | ||
![]() |
1571981252 | ||
![]() |
5805c708d2 | ||
![]() |
ea57dbf750 | ||
![]() |
76e68db06f | ||
![]() |
e4690d5d9c | ||
![]() |
f5ed85427e | ||
![]() |
d83e3056c6 | ||
![]() |
f127a354ef | ||
![]() |
0d5a4c418e | ||
![]() |
969bdb7d24 | ||
![]() |
89d935e27f | ||
![]() |
adc017c48d | ||
![]() |
7e89ab0204 | ||
![]() |
1f2fedf754 | ||
![]() |
d1738a0a3e | ||
![]() |
ee9aefa4fa | ||
![]() |
08afaece2e | ||
![]() |
4f2ba0a96d | ||
![]() |
9db46faabe | ||
![]() |
567af1c66e | ||
![]() |
2485f4ff33 | ||
![]() |
bce51bb2c4 | ||
![]() |
7febd59ad7 | ||
![]() |
1388a1876e | ||
![]() |
aca8b0261e | ||
![]() |
4e20527834 | ||
![]() |
4ed29fe276 | ||
![]() |
b45eecada2 | ||
![]() |
1d70bd132a | ||
![]() |
88694c7e27 | ||
![]() |
3dd255f359 | ||
![]() |
feb7275cf8 | ||
![]() |
da13ca6092 | ||
![]() |
3d3e2eed8c | ||
![]() |
df6d96f5b6 | ||
![]() |
0ec77c33bf | ||
![]() |
98924ac006 | ||
![]() |
4ef9652ede | ||
![]() |
cfb471e578 | ||
![]() |
76e50624e7 | ||
![]() |
34279c8b8c | ||
![]() |
b7de1e3d27 | ||
![]() |
85ee5da025 | ||
![]() |
e5cba605fa | ||
![]() |
6f44200a3c | ||
![]() |
7129fe83da | ||
![]() |
6f8a017bfb | ||
![]() |
55f192f664 | ||
![]() |
edb04c375f | ||
![]() |
38ffdf1bff | ||
![]() |
a885440fef | ||
![]() |
16341ca6da | ||
![]() |
fc219f704c | ||
![]() |
63346f7e38 | ||
![]() |
04be0d1316 | ||
![]() |
65a33f16fd | ||
![]() |
fdec74acc6 | ||
![]() |
231dbc4577 | ||
![]() |
459523dfd2 | ||
![]() |
591824dd0c | ||
![]() |
da928f20a2 | ||
![]() |
a162450568 | ||
![]() |
084218027c | ||
![]() |
bf1aa9e85c | ||
![]() |
afc0f16470 | ||
![]() |
59271d3376 | ||
![]() |
84bd641cf2 | ||
![]() |
1d8e36b46d | ||
![]() |
1625932e52 | ||
![]() |
6a6f1750b1 | ||
![]() |
4252457871 | ||
![]() |
9606978bd7 | ||
![]() |
ebae61a688 | ||
![]() |
43f38a2f44 | ||
![]() |
53d50ca869 | ||
![]() |
fac280ff0a | ||
![]() |
6ae6c58f4c | ||
![]() |
8521995758 | ||
![]() |
19f95c433c | ||
![]() |
45fb337c87 | ||
![]() |
8808f65b47 | ||
![]() |
5cef34a467 | ||
![]() |
8681f75bab | ||
![]() |
c1b61f9cd9 | ||
![]() |
78a018f686 | ||
![]() |
36c9b7648a | ||
![]() |
5c60c7c156 | ||
![]() |
683c948f6c | ||
![]() |
1699146f79 | ||
![]() |
a01661d0d5 | ||
![]() |
1962af01e6 | ||
![]() |
39349dded1 | ||
![]() |
b53509aa69 | ||
![]() |
b5ba9856ed | ||
![]() |
b94df53267 | ||
![]() |
4b42f991f8 | ||
![]() |
2ceff6828a | ||
![]() |
d39eda49de | ||
![]() |
a5d6cf04cf | ||
![]() |
1fbe0746a4 | ||
![]() |
f93659b661 | ||
![]() |
88785aaa32 | ||
![]() |
4143ae8198 | ||
![]() |
f1c48e8a15 | ||
![]() |
6445a5009a | ||
![]() |
112a35c08f | ||
![]() |
7970ac3031 | ||
![]() |
c03f302fa6 | ||
![]() |
0c3a27febd | ||
![]() |
aec00982ba | ||
![]() |
8026533a06 | ||
![]() |
550e1e155b | ||
![]() |
12974ab01b | ||
![]() |
6c067bee31 | ||
![]() |
db4a10171e | ||
![]() |
472cfd6610 | ||
![]() |
e3ed429da1 | ||
![]() |
5ae4d6e7c4 | ||
![]() |
4c3255107c | ||
![]() |
41a3f5dae3 | ||
![]() |
4ca3b509cf | ||
![]() |
28680bec1a | ||
![]() |
ae3141e37b | ||
![]() |
5b900872af | ||
![]() |
754dc3a7b9 | ||
![]() |
8974fb3b49 | ||
![]() |
ce173f2c42 | ||
![]() |
9a1ec76ffd | ||
![]() |
a9be4df157 | ||
![]() |
e884c84aa8 | ||
![]() |
ad5e7646c1 | ||
![]() |
ff1d11f512 | ||
![]() |
5e7cb72b82 | ||
![]() |
f137498e7e | ||
![]() |
d257fbf9a3 | ||
![]() |
a5504e6e80 | ||
![]() |
5968663be4 | ||
![]() |
66cc59c48e | ||
![]() |
f5f735372a | ||
![]() |
91ab1c5ae4 | ||
![]() |
78de8752c6 | ||
![]() |
936da301b8 | ||
![]() |
80c807bfba | ||
![]() |
4583ca00e9 | ||
![]() |
8b87ad92f1 | ||
![]() |
43e110d378 | ||
![]() |
a8217e2632 | ||
![]() |
cf44f45fde | ||
![]() |
30ed700521 |
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)
|
1
.dev/build_dev.sh
Normal file
|
@ -0,0 +1 @@
|
|||
docker build -t flame:dev -f .docker/Dockerfile .
|
2
.dev/build_latest.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker build -t pawelmalak/flame -t "pawelmalak/flame:$1" -f .docker/Dockerfile . \
|
||||
&& docker push pawelmalak/flame && docker push "pawelmalak/flame:$1"
|
6
.dev/build_multiarch.sh
Normal file
|
@ -0,0 +1,6 @@
|
|||
docker buildx build \
|
||||
--platform linux/arm/v7,linux/arm64,linux/amd64 \
|
||||
-f .docker/Dockerfile.multiarch \
|
||||
-t pawelmalak/flame:multiarch \
|
||||
-t "pawelmalak/flame:multiarch$1" \
|
||||
--push .
|
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);
|
||||
};
|
30
.docker/Dockerfile
Normal file
|
@ -0,0 +1,30 @@
|
|||
FROM node:16 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p ./public ./data \
|
||||
&& cd ./client \
|
||||
&& npm install --production \
|
||||
&& npm run build \
|
||||
&& cd .. \
|
||||
&& mv ./client/build/* ./public \
|
||||
&& rm -rf ./client
|
||||
|
||||
FROM node:16-alpine
|
||||
|
||||
COPY --from=builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5005
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PASSWORD=flame_password
|
||||
|
||||
CMD ["sh", "-c", "chown -R node /app/data && node server.js"]
|
26
.docker/Dockerfile.dev
Normal file
|
@ -0,0 +1,26 @@
|
|||
FROM node:lts-alpine as build-front
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./client .
|
||||
|
||||
RUN npm install --production \
|
||||
&& npm run build
|
||||
|
||||
FROM node:lts-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p ./public
|
||||
|
||||
COPY --from=build-front /app/build/ ./public
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "skaffold"]
|
31
.docker/Dockerfile.multiarch
Normal file
|
@ -0,0 +1,31 @@
|
|||
FROM node:16-alpine3.11 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN apk --no-cache --virtual build-dependencies add python python3 make g++ \
|
||||
&& npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p ./public ./data \
|
||||
&& cd ./client \
|
||||
&& npm install --production \
|
||||
&& npm run build \
|
||||
&& cd .. \
|
||||
&& mv ./client/build/* ./public \
|
||||
&& rm -rf ./client
|
||||
|
||||
FROM node:16-alpine3.11
|
||||
|
||||
COPY --from=builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5005
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PASSWORD=flame_password
|
||||
|
||||
CMD ["sh", "-c", "chown -R node /app/data && node server.js"]
|
22
.docker/docker-compose.yml
Normal file
|
@ -0,0 +1,22 @@
|
|||
version: '3.6'
|
||||
|
||||
services:
|
||||
flame:
|
||||
image: pawelmalak/flame
|
||||
container_name: flame
|
||||
volumes:
|
||||
- /path/to/host/data:/app/data
|
||||
# - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration
|
||||
ports:
|
||||
- 5005:5005
|
||||
# secrets:
|
||||
# - password # optional but required for (1)
|
||||
environment:
|
||||
- PASSWORD=flame_password
|
||||
# - PASSWORD_FILE=/run/secrets/password # optional but required for (1)
|
||||
restart: unless-stopped
|
||||
|
||||
# optional but required for Docker secrets (1)
|
||||
# secrets:
|
||||
# password:
|
||||
# file: /path/to/secrets/password
|
|
@ -1,2 +1,6 @@
|
|||
node_modules
|
||||
github
|
||||
.github
|
||||
public
|
||||
k8s
|
||||
skaffold.yaml
|
||||
data
|
5
.env
Normal file
|
@ -0,0 +1,5 @@
|
|||
PORT=5005
|
||||
NODE_ENV=development
|
||||
VERSION=2.3.1
|
||||
PASSWORD=flame_password
|
||||
SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b
|
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: "[BUG] "
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Deployment details:**
|
||||
- App version [e.g. v1.7.4]:
|
||||
- Platform [e.g. amd64, arm64, arm/v7]:
|
||||
- Docker image tag [e.g. latest, multiarch]:
|
||||
|
||||
---
|
||||
|
||||
**Bug description:**
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
---
|
||||
|
||||
**Steps to reproduce:**
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
BIN
.github/apps.png
vendored
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
.github/bookmarks.png
vendored
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
.github/home.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
.github/settings.png
vendored
Normal file
After Width: | Height: | Size: 62 KiB |
BIN
.github/themes.png
vendored
Normal file
After Width: | Height: | Size: 226 KiB |
7
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
node_modules/
|
||||
data/
|
||||
.env
|
||||
node_modules
|
||||
data
|
||||
public
|
||||
!client/public
|
2
.prettierignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
*.md
|
||||
docker-compose.yml
|
8
.prettierrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5"
|
||||
}
|
176
CHANGELOG.md
Normal file
|
@ -0,0 +1,176 @@
|
|||
### v2.3.1 (2023-07-23)
|
||||
- Fixed bug where "Open search results in the same tab" setting was not respected if "Local search" was set as primary search provider ([#270](https://github.com/pawelmalak/flame/issues/270))
|
||||
- Fixed bug where search bar had rounded input field on iOS ([#394](https://github.com/pawelmalak/flame/issues/394))
|
||||
- Updated link to Material Design Icons reference page ([#414](https://github.com/pawelmalak/flame/issues/414))
|
||||
- Fixed bug where color inputs in theme creator/editor were too small ([#429](https://github.com/pawelmalak/flame/issues/429))
|
||||
- Changed input labels in settings for more consistent naming ([#430](https://github.com/pawelmalak/flame/issues/430))
|
||||
|
||||
### v2.3.0 (2022-03-25)
|
||||
- Added custom theme editor ([#246](https://github.com/pawelmalak/flame/issues/246))
|
||||
- Added option to set secondary search provider ([#295](https://github.com/pawelmalak/flame/issues/295))
|
||||
- Fixed bug where pressing Enter with empty search bar would redirect to search results ([#325](https://github.com/pawelmalak/flame/issues/325))
|
||||
- Fixed bug where user could create empty app or bookmark which was causing page to go blank ([#332](https://github.com/pawelmalak/flame/issues/332))
|
||||
- Added new theme: Mint
|
||||
|
||||
### v2.2.2 (2022-03-21)
|
||||
- Added option to get user location directly from the app ([#287](https://github.com/pawelmalak/flame/issues/287))
|
||||
- Fixed bug with local search not working when using prefix ([#289](https://github.com/pawelmalak/flame/issues/289))
|
||||
- Fixed bug with app description not updating when using custom icon ([#310](https://github.com/pawelmalak/flame/issues/310))
|
||||
- Changed permissions to some files and directories created by Flame
|
||||
- Changed some of the settings tabs
|
||||
|
||||
### v2.2.1 (2022-01-08)
|
||||
- Local search will now include app descriptions ([#266](https://github.com/pawelmalak/flame/issues/266))
|
||||
- Fixed bug with unsupported characters in local search [#279](https://github.com/pawelmalak/flame/issues/279))
|
||||
- Background tasks optimization ([#283](https://github.com/pawelmalak/flame/issues/283))
|
||||
|
||||
### v2.2.0 (2021-12-17)
|
||||
- Added option to set custom description for apps ([#201](https://github.com/pawelmalak/flame/issues/201))
|
||||
- Fixed fatal error while deploying Flame to cluster ([#242](https://github.com/pawelmalak/flame/issues/242))
|
||||
|
||||
### v2.1.1 (2021-12-02)
|
||||
- Added support for Docker secrets ([#189](https://github.com/pawelmalak/flame/issues/189))
|
||||
- Changed some messages and buttons to make it easier to open bookmarks editor ([#239](https://github.com/pawelmalak/flame/issues/239))
|
||||
|
||||
### v2.1.0 (2021-11-26)
|
||||
- Added option to set custom order for bookmarks ([#43](https://github.com/pawelmalak/flame/issues/43)) and ([#187](https://github.com/pawelmalak/flame/issues/187))
|
||||
- Added support for .ico files for custom icons ([#209](https://github.com/pawelmalak/flame/issues/209))
|
||||
- Empty apps and categories sections will now be hidden from guests ([#210](https://github.com/pawelmalak/flame/issues/210))
|
||||
- Fixed bug with fahrenheit degrees being displayed as float ([#221](https://github.com/pawelmalak/flame/issues/221))
|
||||
- Fixed bug with alphabetical order not working for bookmarks until the page was refreshed ([#224](https://github.com/pawelmalak/flame/issues/224))
|
||||
- Added option to change visibilty of apps, categories and bookmarks directly from table view
|
||||
- Password input will now autofocus when visiting /settings/app
|
||||
|
||||
### v2.0.1 (2021-11-19)
|
||||
- Added option to display humidity in the weather widget ([#136](https://github.com/pawelmalak/flame/issues/136))
|
||||
- Added option to set default theme for all new users ([#165](https://github.com/pawelmalak/flame/issues/165))
|
||||
- Added option to hide header greetings and date separately ([#200](https://github.com/pawelmalak/flame/issues/200))
|
||||
- Fixed bug with broken basic auth ([#202](https://github.com/pawelmalak/flame/issues/202))
|
||||
- Fixed bug with parsing visibility value for apps and bookmarks when custom icon was used ([#203](https://github.com/pawelmalak/flame/issues/203))
|
||||
- Fixed bug with custom icons not working with apps when "pin by default" was disabled
|
||||
|
||||
### v2.0.0 (2021-11-15)
|
||||
- Added authentication system:
|
||||
- Only logged in user can access settings ([#33](https://github.com/pawelmalak/flame/issues/33))
|
||||
- User can set which apps, categories and bookmarks should be available for guest users ([#45](https://github.com/pawelmalak/flame/issues/45))
|
||||
- Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about this feature
|
||||
- Docker images will now be versioned ([#110](https://github.com/pawelmalak/flame/issues/110))
|
||||
- Icons can now be set via URL ([#138](https://github.com/pawelmalak/flame/issues/138))
|
||||
- Added current time to the header ([#157](https://github.com/pawelmalak/flame/issues/157))
|
||||
- Fixed bug where typing certain characters in the search bar would result in a blank page ([#158](https://github.com/pawelmalak/flame/issues/158))
|
||||
- Fixed bug with MDI icon name not being properly parsed if there was leading or trailing whitespace ([#164](https://github.com/pawelmalak/flame/issues/164))
|
||||
- Added new shortcut to clear search bar and focus on it ([#170](https://github.com/pawelmalak/flame/issues/170))
|
||||
- Added Wikipedia to search queries
|
||||
- Updated project wiki
|
||||
- Lots of changes and refactors under the hood to make future development easier
|
||||
|
||||
### v1.7.4 (2021-11-08)
|
||||
- Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103))
|
||||
- Fallback to web search if local search has zero results ([#129](https://github.com/pawelmalak/flame/issues/129))
|
||||
- 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.0 (2021-07-17)
|
||||
- Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
|
||||
- Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
|
||||
- Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
|
||||
|
||||
### v1.5.0 (2021-06-24)
|
||||
- Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental)
|
||||
- Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12))
|
||||
- Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27))
|
||||
- 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.0 (2021-06-18)
|
||||
- Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
|
||||
- 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.0 (2021-06-14)
|
||||
- Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
|
||||
- Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
|
||||
- Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
|
||||
- Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
|
||||
- Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
|
||||
|
||||
### v1.2.0 (2021-06-10)
|
||||
- Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2))
|
||||
- Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7))
|
||||
- Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11))
|
||||
- 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.0 (2021-06-09)
|
||||
- Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4))
|
||||
- Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5))
|
||||
|
||||
### v1.0.0 (2021-06-08)
|
||||
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.
|
22
Dockerfile
|
@ -1,22 +0,0 @@
|
|||
FROM node:14-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json .
|
||||
|
||||
RUN npm install --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p ./public ./data \
|
||||
&& cd ./client \
|
||||
&& npm run build \
|
||||
&& cd .. \
|
||||
&& mv ./client/build/* ./public \
|
||||
&& rm -rf ./client
|
||||
|
||||
EXPOSE 5005
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "server.js"]
|
21
LICENSE.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021 Paweł Malak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
238
README.md
|
@ -1,11 +1,113 @@
|
|||
# Flame
|
||||
|
||||

|
||||

|
||||
|
||||
## Description
|
||||
Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui)
|
||||
|
||||
## Technology
|
||||
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary.
|
||||
|
||||
## Functionality
|
||||
- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors
|
||||
- 📌 Pin your favourite items to the homescreen for quick and easy access
|
||||
- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
|
||||
- 🔑 Authentication system to protect your settings, apps and bookmarks
|
||||
- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS, 15 built-in color themes and custom theme builder
|
||||
- ☀️ Weather widget with current temperature, cloud coverage and animated weather status
|
||||
- 🐳 Docker integration to automatically pick and add apps based on their labels
|
||||
|
||||
## Installation
|
||||
|
||||
### With Docker (recommended)
|
||||
|
||||
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
|
||||
|
||||
```sh
|
||||
docker pull pawelmalak/flame
|
||||
|
||||
# for ARM architecture (e.g. RaspberryPi)
|
||||
docker pull pawelmalak/flame:multiarch
|
||||
|
||||
# installing specific version
|
||||
docker pull pawelmalak/flame:2.0.0
|
||||
```
|
||||
|
||||
#### Deployment
|
||||
|
||||
```sh
|
||||
# run container
|
||||
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password pawelmalak/flame
|
||||
```
|
||||
|
||||
#### Building images
|
||||
|
||||
```sh
|
||||
# build image for amd64 only
|
||||
docker build -t flame -f .docker/Dockerfile .
|
||||
|
||||
# build multiarch image for amd64, armv7 and arm64
|
||||
# building failed multiple times with 2GB memory usage limit so you might want to increase it
|
||||
docker buildx build \
|
||||
--platform linux/arm/v7,linux/arm64,linux/amd64 \
|
||||
-f .docker/Dockerfile.multiarch \
|
||||
-t flame:multiarch .
|
||||
```
|
||||
|
||||
#### Docker-Compose
|
||||
|
||||
```yaml
|
||||
version: '3.6'
|
||||
|
||||
services:
|
||||
flame:
|
||||
image: pawelmalak/flame
|
||||
container_name: flame
|
||||
volumes:
|
||||
- /path/to/host/data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration
|
||||
ports:
|
||||
- 5005:5005
|
||||
secrets:
|
||||
- password # optional but required for (1)
|
||||
environment:
|
||||
- PASSWORD=flame_password
|
||||
- PASSWORD_FILE=/run/secrets/password # optional but required for (1)
|
||||
restart: unless-stopped
|
||||
|
||||
# optional but required for Docker secrets (1)
|
||||
secrets:
|
||||
password:
|
||||
file: /path/to/secrets/password
|
||||
```
|
||||
|
||||
##### Docker Secrets
|
||||
|
||||
All environment variables can be overwritten by appending `_FILE` to the variable value. For example, you can use `PASSWORD_FILE` to pass through a docker secret instead of `PASSWORD`. If both `PASSWORD` and `PASSWORD_FILE` are set, the docker secret will take precedent.
|
||||
|
||||
```bash
|
||||
# ./secrets/flame_password
|
||||
my_custom_secret_password_123
|
||||
|
||||
# ./docker-compose.yml
|
||||
secrets:
|
||||
password:
|
||||
file: ./secrets/flame_password
|
||||
```
|
||||
|
||||
#### Skaffold
|
||||
|
||||
```sh
|
||||
# use skaffold
|
||||
skaffold dev
|
||||
```
|
||||
|
||||
### Without Docker
|
||||
|
||||
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
|
||||
|
||||
## Development
|
||||
|
||||
### Technology
|
||||
|
||||
- Backend
|
||||
- Node.js + Express
|
||||
- Sequelize ORM + SQLite
|
||||
|
@ -15,9 +117,12 @@ Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI]
|
|||
- TypeScript
|
||||
- Deployment
|
||||
- Docker
|
||||
- Kubernetes
|
||||
|
||||
### Creating dev environment
|
||||
|
||||
## Development
|
||||
```sh
|
||||
# clone repository
|
||||
git clone https://github.com/pawelmalak/flame
|
||||
cd flame
|
||||
|
||||
|
@ -28,31 +133,116 @@ npm run dev-init
|
|||
npm run dev
|
||||
```
|
||||
|
||||
## Deployment with Docker
|
||||
```sh
|
||||
# build image
|
||||
docker build -t flame .
|
||||
## Screenshots
|
||||
|
||||
# run container
|
||||
docker run -p 5005:5005 -v <host_dir>:/app/data flame
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
### Authentication
|
||||
|
||||
Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
|
||||
|
||||
### Search bar
|
||||
|
||||
#### Searching
|
||||
|
||||
The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
|
||||
|
||||
For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar).
|
||||
|
||||
### Setting up weather module
|
||||
|
||||
1. Obtain API Key from [Weather API](https://www.weatherapi.com/pricing.aspx).
|
||||
> Free plan allows for 1M calls per month. Flame is making less then 3K API calls per month.
|
||||
2. Get lat/long for your location. You can get them from [latlong.net](https://www.latlong.net/convert-address-to-lat-long.html).
|
||||
3. Enter and save data. Weather widget will now update and should be visible on Home page.
|
||||
|
||||
### Docker integration
|
||||
|
||||
In order to use the Docker integration, each container must have the following labels:
|
||||
|
||||
```yml
|
||||
labels:
|
||||
- flame.type=application # "app" works too
|
||||
- flame.name=My container
|
||||
- flame.url=https://example.com
|
||||
- flame.icon=icon-name # optional, default is "docker"
|
||||
# - flame.icon=custom to make changes in app. ie: custom icon upload
|
||||
```
|
||||
|
||||
## Functionality
|
||||
- Applications
|
||||
- Create, update and delete applications using GUI
|
||||
- Pin your favourite apps to homescreen
|
||||
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker
|
||||
|
||||

|
||||
You can also set up different apps in the same label adding `;` between each one.
|
||||
|
||||
- Bookmarks
|
||||
- Create, update and delete bookmarks and categories using GUI
|
||||
- Pin your favourite categories to homescreen
|
||||
```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:
|
||||
|
||||
- Weather
|
||||
- Get current temperature, cloud coverage and weather status with animated icons
|
||||
- Themes
|
||||
- Customize your page by choosing from 12 color themes
|
||||
- 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 > Docker
|
||||
|
||||
### Import HTML Bookmarks (Experimental)
|
||||
|
||||
- Requirements
|
||||
- python3
|
||||
- pip packages: Pillow, beautifulsoup4
|
||||
- Backup your `db.sqlite` before running script!
|
||||
- Known Issues:
|
||||
- generated icons are sometimes incorrect
|
||||
|
||||
```bash
|
||||
pip3 install Pillow, beautifulsoup4
|
||||
|
||||
cd flame/.dev
|
||||
python3 bookmarks_importer.py --bookmarks <path to bookmarks.html> --data <path to flame data folder>
|
||||
```
|
||||
|
||||
### Custom CSS and themes
|
||||
|
||||
See project wiki for [Custom CSS](https://github.com/pawelmalak/flame/wiki/Custom-CSS) and [Custom theme with CSS](https://github.com/pawelmalak/flame/wiki/Custom-theme-with-CSS).
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
const WebSocket = require('ws');
|
||||
const Logger = require('./utils/Logger');
|
||||
const logger = new Logger();
|
||||
|
||||
class Socket {
|
||||
constructor(server) {
|
||||
this.webSocketServer = new WebSocket.Server({ server })
|
||||
|
||||
this.webSocketServer.on('listening', () => {
|
||||
console.log('socket listen');
|
||||
logger.log('Socket: listen');
|
||||
})
|
||||
|
||||
this.webSocketServer.on('connection', (webSocketClient) => {
|
||||
console.log('new connection');
|
||||
// console.log('Socket: new connection');
|
||||
})
|
||||
}
|
||||
|
||||
|
|
16
api.js
|
@ -1,14 +1,15 @@
|
|||
const path = require('path');
|
||||
const { join } = require('path');
|
||||
const express = require('express');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const { errorHandler } = require('./middleware');
|
||||
|
||||
const api = express();
|
||||
|
||||
// Static files
|
||||
api.use(express.static(path.join(__dirname, 'public')));
|
||||
api.use(express.static(join(__dirname, 'public')));
|
||||
api.use('/uploads', express.static(join(__dirname, 'data/uploads')));
|
||||
api.get(/^\/(?!api)/, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public/index.html'));
|
||||
})
|
||||
res.sendFile(join(__dirname, 'public/index.html'));
|
||||
});
|
||||
|
||||
// Body parser
|
||||
api.use(express.json());
|
||||
|
@ -19,8 +20,11 @@ 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'));
|
||||
api.use('/api/auth', require('./routes/auth'));
|
||||
api.use('/api/themes', require('./routes/themes'));
|
||||
|
||||
// Custom error handler
|
||||
api.use(errorHandler);
|
||||
|
||||
module.exports = api;
|
||||
module.exports = api;
|
||||
|
|
1
client/.env
Normal file
|
@ -0,0 +1 @@
|
|||
REACT_APP_VERSION=2.3.1
|
|
@ -1,46 +0,0 @@
|
|||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
33721
client/package-lock.json
generated
|
@ -3,29 +3,34 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@mdi/js": "^5.9.55",
|
||||
"@mdi/js": "^6.4.95",
|
||||
"@mdi/react": "^1.5.0",
|
||||
"@testing-library/jest-dom": "^5.12.0",
|
||||
"@testing-library/react": "^11.2.6",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/node": "^12.20.12",
|
||||
"@types/react": "^17.0.5",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@testing-library/jest-dom": "^5.15.0",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/react": "^17.0.34",
|
||||
"@types/react-beautiful-dnd": "^13.1.2",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-redux": "^7.1.20",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.24.0",
|
||||
"external-svg-loader": "^1.3.4",
|
||||
"http-proxy-middleware": "^2.0.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"redux": "^4.1.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"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",
|
||||
|
@ -51,5 +56,7 @@
|
|||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:5005"
|
||||
"devDependencies": {
|
||||
"prettier": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 3.8 KiB |
BIN
client/public/icons/apple-touch-icon-114x114.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
client/public/icons/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
client/public/icons/apple-touch-icon-144x144.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
client/public/icons/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
client/public/icons/apple-touch-icon-180x180.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
client/public/icons/apple-touch-icon-57x57.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
client/public/icons/apple-touch-icon-72x72.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
client/public/icons/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
client/public/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
client/public/icons/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -2,44 +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="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
content="Flame - self-hosted startpage for your server"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet">
|
||||
<title>React App</title>
|
||||
<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>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
|
|
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB |
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
|
@ -1,3 +1,2 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Disallow: /
|
|
@ -1,37 +1,91 @@
|
|||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Route, Switch } from 'react-router-dom';
|
||||
import { setTheme } from './store/actions';
|
||||
import 'external-svg-loader';
|
||||
|
||||
// Redux
|
||||
import store from './store/store';
|
||||
import { Provider } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { autoLogin, getConfig } from './store/action-creators';
|
||||
import { actionCreators, store } from './store';
|
||||
import { State } from './store/reducers';
|
||||
|
||||
import classes from './App.module.css';
|
||||
// Utils
|
||||
import { checkVersion, decodeToken, parsePABToTheme } from './utility';
|
||||
|
||||
import Home from './components/Home/Home';
|
||||
import Apps from './components/Apps/Apps';
|
||||
import Settings from './components/Settings/Settings';
|
||||
import Bookmarks from './components/Bookmarks/Bookmarks';
|
||||
// Routes
|
||||
import { Home } from './components/Home/Home';
|
||||
import { Apps } from './components/Apps/Apps';
|
||||
import { Settings } from './components/Settings/Settings';
|
||||
import { Bookmarks } from './components/Bookmarks/Bookmarks';
|
||||
import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
|
||||
|
||||
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
|
||||
// Get config
|
||||
store.dispatch<any>(getConfig());
|
||||
|
||||
if (localStorage.theme) {
|
||||
store.dispatch<any>(setTheme(localStorage.theme));
|
||||
// Validate token
|
||||
if (localStorage.token) {
|
||||
store.dispatch<any>(autoLogin());
|
||||
}
|
||||
|
||||
const App = (): JSX.Element => {
|
||||
export const App = (): JSX.Element => {
|
||||
const { config, loading } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispath = useDispatch();
|
||||
const { fetchQueries, setTheme, logout, createNotification, fetchThemes } =
|
||||
bindActionCreators(actionCreators, dispath);
|
||||
|
||||
useEffect(() => {
|
||||
// check if token is valid
|
||||
const tokenIsValid = setInterval(() => {
|
||||
if (localStorage.token) {
|
||||
const expiresIn = decodeToken(localStorage.token).exp * 1000;
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (now > expiresIn) {
|
||||
logout();
|
||||
createNotification({
|
||||
title: 'Info',
|
||||
message: 'Session expired. You have been logged out',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// load themes
|
||||
fetchThemes();
|
||||
|
||||
// set user theme if present
|
||||
if (localStorage.theme) {
|
||||
setTheme(parsePABToTheme(localStorage.theme));
|
||||
}
|
||||
|
||||
// check for updated
|
||||
checkVersion();
|
||||
|
||||
// load custom search queries
|
||||
fetchQueries();
|
||||
|
||||
return () => window.clearInterval(tokenIsValid);
|
||||
}, []);
|
||||
|
||||
// If there is no user theme, set the default one
|
||||
useEffect(() => {
|
||||
if (!loading && !localStorage.theme) {
|
||||
setTheme(parsePABToTheme(config.defaultTheme), false);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
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
|
@ -9,4 +9,4 @@
|
|||
|
||||
.TableAction:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
81
client/src/components/Actions/TableActions.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { Icon } from '../UI';
|
||||
import classes from './TableActions.module.css';
|
||||
|
||||
interface Entity {
|
||||
id: number;
|
||||
name: string;
|
||||
isPinned?: boolean;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entity: Entity;
|
||||
deleteHandler: (id: number, name: string) => void;
|
||||
updateHandler: (id: number) => void;
|
||||
pinHanlder?: (id: number) => void;
|
||||
changeVisibilty: (id: number) => void;
|
||||
showPin?: boolean;
|
||||
}
|
||||
|
||||
export const TableActions = (props: Props): JSX.Element => {
|
||||
const {
|
||||
entity,
|
||||
deleteHandler,
|
||||
updateHandler,
|
||||
pinHanlder,
|
||||
changeVisibilty,
|
||||
showPin = true,
|
||||
} = props;
|
||||
|
||||
const _pinHandler = pinHanlder || function () {};
|
||||
|
||||
return (
|
||||
<td className={classes.TableActions}>
|
||||
{/* DELETE */}
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => deleteHandler(entity.id, entity.name)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon icon="mdiDelete" />
|
||||
</div>
|
||||
|
||||
{/* UPDATE */}
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => updateHandler(entity.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon icon="mdiPencil" />
|
||||
</div>
|
||||
|
||||
{/* PIN */}
|
||||
{showPin && (
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => _pinHandler(entity.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{entity.isPinned ? (
|
||||
<Icon icon="mdiPinOff" color="var(--color-accent)" />
|
||||
) : (
|
||||
<Icon icon="mdiPin" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VISIBILITY */}
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => changeVisibilty(entity.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{entity.isPublic ? (
|
||||
<Icon icon="mdiEyeOff" color="var(--color-accent)" />
|
||||
) : (
|
||||
<Icon icon="mdiEye" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
};
|
|
@ -27,4 +27,24 @@
|
|||
font-weight: 400;
|
||||
font-size: 0.8em;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.AppCard {
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.AppCard:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.CustomIcon {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
|
|
@ -1,41 +1,61 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classes from './AppCard.module.css';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import { Icon } from '../../UI';
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
|
||||
import { App } from '../../../interfaces';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
app: App;
|
||||
pinHandler?: Function;
|
||||
}
|
||||
|
||||
const AppCard = (props: ComponentProps): JSX.Element => {
|
||||
const iconParser = (mdiName: string): string => {
|
||||
let parsedName = mdiName
|
||||
.split('-')
|
||||
.map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
|
||||
.join('');
|
||||
parsedName = `mdi${parsedName}`;
|
||||
export const AppCard = ({ app }: Props): JSX.Element => {
|
||||
const { config } = useSelector((state: State) => state.config);
|
||||
|
||||
return parsedName;
|
||||
}
|
||||
const [displayUrl, redirectUrl] = urlParser(app.url);
|
||||
|
||||
const redirectHandler = (url: string): void => {
|
||||
window.open(url);
|
||||
let iconEl: JSX.Element;
|
||||
const { icon } = app;
|
||||
|
||||
if (isImage(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<img
|
||||
src={source}
|
||||
alt={`${app.name} icon`}
|
||||
className={classes.CustomIcon}
|
||||
/>
|
||||
);
|
||||
} else if (isSvg(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.CustomIcon}>
|
||||
<svg
|
||||
data-src={source}
|
||||
fill="var(--color-primary)"
|
||||
className={classes.CustomIcon}
|
||||
></svg>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
iconEl = <Icon icon={iconParser(icon)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={`http://${props.app.url}`} target='blank' className={classes.AppCard}>
|
||||
<div className={classes.AppCardIcon}>
|
||||
<Icon icon={iconParser(props.app.icon)} />
|
||||
</div>
|
||||
<a
|
||||
href={redirectUrl}
|
||||
target={config.appsSameTab ? '' : '_blank'}
|
||||
rel="noreferrer"
|
||||
className={classes.AppCard}
|
||||
>
|
||||
<div className={classes.AppCardIcon}>{iconEl}</div>
|
||||
<div className={classes.AppCardDetails}>
|
||||
<h5>{props.app.name}</h5>
|
||||
<span>{props.app.url}</span>
|
||||
<h5>{app.name}</h5>
|
||||
<span>{!app.description.length ? displayUrl : app.description}</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppCard;
|
||||
);
|
||||
};
|
||||
|
|
7
client/src/components/Apps/AppForm/AppForm.module.css
Normal file
|
@ -0,0 +1,7 @@
|
|||
.Switch {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.Switch:hover {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -1,131 +1,225 @@
|
|||
import { useState, useEffect, useRef, ChangeEvent, SyntheticEvent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { addApp, updateApp } from '../../../store/actions';
|
||||
import { App, NewApp } from '../../../interfaces';
|
||||
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { NewApp } from '../../../interfaces';
|
||||
|
||||
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import classes from './AppForm.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
import { inputHandler, newAppTemplate } from '../../../utility';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
addApp: (formData: NewApp) => any;
|
||||
updateApp: (id: number, formData: NewApp) => any;
|
||||
app?: App;
|
||||
}
|
||||
|
||||
const AppForm = (props: ComponentProps): JSX.Element => {
|
||||
const [formData, setFormData] = useState<NewApp>({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: ''
|
||||
});
|
||||
export const AppForm = ({ modalHandler }: Props): JSX.Element => {
|
||||
const { appInUpdate } = useSelector((state: State) => state.apps);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dispatch = useDispatch();
|
||||
const { addApp, updateApp, setEditApp, createNotification } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
const [formData, setFormData] = useState<NewApp>(newAppTemplate);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.app) {
|
||||
if (appInUpdate) {
|
||||
setFormData({
|
||||
name: props.app.name,
|
||||
url: props.app.url,
|
||||
icon: props.app.icon
|
||||
})
|
||||
...appInUpdate,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: ''
|
||||
})
|
||||
setFormData(newAppTemplate);
|
||||
}
|
||||
}, [props.app])
|
||||
}, [appInUpdate]);
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<NewApp>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.target.files) {
|
||||
setCustomIcon(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!props.app) {
|
||||
props.addApp(formData);
|
||||
} else {
|
||||
props.updateApp(props.app.id, formData);
|
||||
props.modalHandler();
|
||||
for (let field of ['name', 'url', 'icon'] as const) {
|
||||
if (/^ +$/.test(formData[field])) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: `Field cannot be empty: ${field}`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: ''
|
||||
})
|
||||
}
|
||||
const createFormData = (): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
if (customIcon) {
|
||||
data.append('icon', customIcon);
|
||||
}
|
||||
|
||||
data.append('name', formData.name);
|
||||
data.append('description', formData.description);
|
||||
data.append('url', formData.url);
|
||||
data.append('isPublic', `${formData.isPublic ? 1 : 0}`);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
if (!appInUpdate) {
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
addApp(data);
|
||||
} else {
|
||||
addApp(formData);
|
||||
}
|
||||
} else {
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
updateApp(appInUpdate.id, data);
|
||||
} else {
|
||||
updateApp(appInUpdate.id, formData);
|
||||
modalHandler();
|
||||
}
|
||||
}
|
||||
|
||||
setFormData(newAppTemplate);
|
||||
setEditApp(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
modalHandler={props.modalHandler}
|
||||
formHandler={formSubmitHandler}
|
||||
>
|
||||
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
|
||||
{/* NAME */}
|
||||
<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>
|
||||
|
||||
{/* URL */}
|
||||
<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>Use URL without protocol</span>
|
||||
</InputGroup>
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<InputGroup>
|
||||
<label htmlFor='icon'>App Icon</label>
|
||||
<label htmlFor="description">App description</label>
|
||||
<input
|
||||
type='text'
|
||||
name='icon'
|
||||
id='icon'
|
||||
placeholder='book-open-outline'
|
||||
required
|
||||
value={formData.icon}
|
||||
type="text"
|
||||
name="description"
|
||||
id="description"
|
||||
placeholder="My self-hosted app"
|
||||
value={formData.description}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
<span>
|
||||
Use icon name from MDI.
|
||||
<a
|
||||
href='https://materialdesignicons.com/'
|
||||
target='blank'>
|
||||
{' '}Click here for reference
|
||||
</a>
|
||||
Optional - If description is not set, app URL will be displayed
|
||||
</span>
|
||||
</InputGroup>
|
||||
{!props.app
|
||||
? <Button>Add new application</Button>
|
||||
: <Button>Update application</Button>
|
||||
}
|
||||
</ModalForm>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(null, { addApp, updateApp })(AppForm);
|
||||
{/* ICON */}
|
||||
{!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 or pass a valid URL.
|
||||
<a href="https://pictogrammers.com/library/mdi/" target="blank">
|
||||
{' '}
|
||||
Click here for reference
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||
className={classes.Switch}
|
||||
>
|
||||
Switch to custom icon upload
|
||||
</span>
|
||||
</InputGroup>
|
||||
) : (
|
||||
// upload custom icon
|
||||
<InputGroup>
|
||||
<label htmlFor="icon">App Icon</label>
|
||||
<input
|
||||
type="file"
|
||||
name="icon"
|
||||
id="icon"
|
||||
required
|
||||
onChange={(e) => fileChangeHandler(e)}
|
||||
accept=".jpg,.jpeg,.png,.svg,.ico"
|
||||
/>
|
||||
<span
|
||||
onClick={() => {
|
||||
setCustomIcon(null);
|
||||
toggleUseCustomIcon(!useCustomIcon);
|
||||
}}
|
||||
className={classes.Switch}
|
||||
>
|
||||
Switch to MDI
|
||||
</span>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
{/* VISIBILITY */}
|
||||
<InputGroup>
|
||||
<label htmlFor="isPublic">App visibility</label>
|
||||
<select
|
||||
id="isPublic"
|
||||
name="isPublic"
|
||||
value={formData.isPublic ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>Visible (anyone can access it)</option>
|
||||
<option value={0}>Hidden (authentication required)</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{!appInUpdate ? (
|
||||
<Button>Add new application</Button>
|
||||
) : (
|
||||
<Button>Update application</Button>
|
||||
)}
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,21 +20,3 @@
|
|||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.GridMessage {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.GridMessage a {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.AppsMessage {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.AppsMessage a {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
|
@ -2,33 +2,47 @@ import classes from './AppGrid.module.css';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { App } from '../../../interfaces/App';
|
||||
|
||||
import AppCard from '../AppCard/AppCard';
|
||||
import { AppCard } from '../AppCard/AppCard';
|
||||
import { Message } from '../../UI';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
apps: App[];
|
||||
totalApps?: number;
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
const AppGrid = (props: ComponentProps): JSX.Element => {
|
||||
export const AppGrid = (props: Props): JSX.Element => {
|
||||
let apps: JSX.Element;
|
||||
|
||||
if (props.apps.length > 0) {
|
||||
apps = (
|
||||
<div className={classes.AppGrid}>
|
||||
{props.apps.map((app: App): JSX.Element => {
|
||||
return <AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
if (props.searching || props.apps.length) {
|
||||
if (!props.apps.length) {
|
||||
apps = <Message>No apps match your search criteria</Message>;
|
||||
} else {
|
||||
apps = (
|
||||
<div className={classes.AppGrid}>
|
||||
{props.apps.map((app: App): JSX.Element => {
|
||||
return <AppCard key={app.id} app={app} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apps = (
|
||||
<p className={classes.AppsMessage}>You don't have any applications. You can add a new one from <Link to='/applications'>/application</Link> menu</p>
|
||||
);
|
||||
if (props.totalApps) {
|
||||
apps = (
|
||||
<Message>
|
||||
There are no pinned applications. You can pin them from the{' '}
|
||||
<Link to="/applications">/applications</Link> menu
|
||||
</Message>
|
||||
);
|
||||
} else {
|
||||
apps = (
|
||||
<Message>
|
||||
You don't have any applications. You can add a new one from{' '}
|
||||
<Link to="/applications">/applications</Link> menu
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
export default AppGrid;
|
||||
};
|
||||
|
|
|
@ -1,84 +1,160 @@
|
|||
import { KeyboardEvent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { App, GlobalState } from '../../../interfaces';
|
||||
import { pinApp, deleteApp } from '../../../store/actions';
|
||||
import { Fragment, useState, useEffect } from 'react';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classes from './AppTable.module.css';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import Table from '../../UI/Table/Table';
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
interface ComponentProps {
|
||||
apps: App[];
|
||||
pinApp: (app: App) => void;
|
||||
deleteApp: (id: number) => void;
|
||||
updateAppHandler: (app: App) => void;
|
||||
// Typescript
|
||||
import { App } from '../../../interfaces';
|
||||
|
||||
// Other
|
||||
import { Message, Table } from '../../UI';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
|
||||
interface Props {
|
||||
openFormForUpdating: (app: App) => void;
|
||||
}
|
||||
|
||||
const AppTable = (props: ComponentProps): JSX.Element => {
|
||||
const deleteAppHandler = (app: App): void => {
|
||||
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
|
||||
export const AppTable = (props: Props): JSX.Element => {
|
||||
const {
|
||||
apps: { apps },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { pinApp, deleteApp, reorderApps, createNotification, updateApp } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||
|
||||
// Copy apps array
|
||||
useEffect(() => {
|
||||
setLocalApps([...apps]);
|
||||
}, [apps]);
|
||||
|
||||
const dragEndHanlder = (result: DropResult): void => {
|
||||
if (config.useOrdering !== 'orderId') {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Custom order is disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpApps = [...localApps];
|
||||
const [movedApp] = tmpApps.splice(result.source.index, 1);
|
||||
tmpApps.splice(result.destination.index, 0, movedApp);
|
||||
|
||||
setLocalApps(tmpApps);
|
||||
reorderApps(tmpApps);
|
||||
};
|
||||
|
||||
// Action handlers
|
||||
const deleteAppHandler = (id: number, name: string) => {
|
||||
const proceed = window.confirm(`Are you sure you want to delete ${name}?`);
|
||||
|
||||
if (proceed) {
|
||||
props.deleteApp(app.id);
|
||||
deleteApp(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
|
||||
if (e.key === 'Enter') {
|
||||
handler(app);
|
||||
}
|
||||
}
|
||||
const updateAppHandler = (id: number) => {
|
||||
const app = apps.find((a) => a.id === id) as App;
|
||||
props.openFormForUpdating(app);
|
||||
};
|
||||
|
||||
const pinAppHandler = (id: number) => {
|
||||
const app = apps.find((a) => a.id === id) as App;
|
||||
pinApp(app);
|
||||
};
|
||||
|
||||
const changeAppVisibiltyHandler = (id: number) => {
|
||||
const app = apps.find((a) => a.id === id) as App;
|
||||
updateApp(id, { ...app, isPublic: !app.isPublic });
|
||||
};
|
||||
|
||||
return (
|
||||
<Table headers={[
|
||||
'Name',
|
||||
'URL',
|
||||
'Icon',
|
||||
'Actions'
|
||||
]}>
|
||||
{props.apps.map((app: App): JSX.Element => {
|
||||
return (
|
||||
<tr key={app.id}>
|
||||
<td>{app.name}</td>
|
||||
<td>{app.url}</td>
|
||||
<td>{app.icon}</td>
|
||||
<td className={classes.TableActions}>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => deleteAppHandler(app)}
|
||||
onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
||||
tabIndex={0}>
|
||||
<Icon icon='mdiDelete' />
|
||||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.updateAppHandler(app)}
|
||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
||||
tabIndex={0}>
|
||||
<Icon icon='mdiPencil' />
|
||||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.pinApp(app)}
|
||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
|
||||
tabIndex={0}>
|
||||
{app.isPinned
|
||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
||||
: <Icon icon='mdiPin' />
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
<Fragment>
|
||||
<Message isPrimary={false}>
|
||||
{config.useOrdering === 'orderId' ? (
|
||||
<p>You can drag and drop single rows to reorder application</p>
|
||||
) : (
|
||||
<p>
|
||||
Custom order is disabled. You can change it in the{' '}
|
||||
<Link to="/settings/general">settings</Link>
|
||||
</p>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
apps: state.app.apps
|
||||
}
|
||||
}
|
||||
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||
<Droppable droppableId="apps">
|
||||
{(provided) => (
|
||||
<Table
|
||||
headers={['Name', 'URL', 'Icon', 'Visibility', 'Actions']}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{localApps.map((app: App, index): JSX.Element => {
|
||||
return (
|
||||
<Draggable
|
||||
key={app.id}
|
||||
draggableId={app.id.toString()}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => {
|
||||
const style = {
|
||||
border: snapshot.isDragging
|
||||
? '1px solid var(--color-accent)'
|
||||
: 'none',
|
||||
borderRadius: '4px',
|
||||
...provided.draggableProps.style,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { pinApp, deleteApp })(AppTable);
|
||||
return (
|
||||
<tr
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
style={style}
|
||||
>
|
||||
<td style={{ width: '200px' }}>{app.name}</td>
|
||||
<td style={{ width: '200px' }}>{app.url}</td>
|
||||
<td style={{ width: '200px' }}>{app.icon}</td>
|
||||
<td style={{ width: '200px' }}>
|
||||
{app.isPublic ? 'Visible' : 'Hidden'}
|
||||
</td>
|
||||
|
||||
{!snapshot.isDragging && (
|
||||
<TableActions
|
||||
entity={app}
|
||||
deleteHandler={deleteAppHandler}
|
||||
updateHandler={updateAppHandler}
|
||||
pinHanlder={pinAppHandler}
|
||||
changeVisibilty={changeAppVisibiltyHandler}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,114 +1,110 @@
|
|||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { getApps } from '../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { App, GlobalState } from '../../interfaces';
|
||||
import { App } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './Apps.module.css';
|
||||
|
||||
// UI
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
|
||||
import Modal from '../UI/Modal/Modal';
|
||||
import { Headline, Spinner, ActionButton, Modal, Container } from '../UI';
|
||||
|
||||
// Subcomponents
|
||||
import AppGrid from './AppGrid/AppGrid';
|
||||
import AppForm from './AppForm/AppForm';
|
||||
import AppTable from './AppTable/AppTable';
|
||||
import { AppGrid } from './AppGrid/AppGrid';
|
||||
import { AppForm } from './AppForm/AppForm';
|
||||
import { AppTable } from './AppTable/AppTable';
|
||||
|
||||
interface ComponentProps {
|
||||
getApps: Function;
|
||||
apps: App[];
|
||||
loading: boolean;
|
||||
// Utils
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
interface Props {
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
const Apps = (props: ComponentProps): JSX.Element => {
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [isInEdit, setIsInEdit] = useState(false);
|
||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||
const [appInUpdate, setAppInUpdate] = useState<App>({
|
||||
name: 'string',
|
||||
url: 'string',
|
||||
icon: 'string',
|
||||
isPinned: false,
|
||||
id: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
export const Apps = (props: Props): JSX.Element => {
|
||||
// Get Redux state
|
||||
const {
|
||||
apps: { apps, loading },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
// Get Redux action creators
|
||||
const dispatch = useDispatch();
|
||||
const { getApps, setEditApp } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
// Load apps if array is empty
|
||||
useEffect(() => {
|
||||
if (props.apps.length === 0) {
|
||||
props.getApps();
|
||||
if (!apps.length) {
|
||||
getApps();
|
||||
}
|
||||
}, [props.getApps]);
|
||||
}, []);
|
||||
|
||||
// Form
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
|
||||
// Observe if user is authenticated -> set default view if not
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setShowTable(false);
|
||||
setModalIsOpen(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Form actions
|
||||
const toggleModal = (): void => {
|
||||
setModalIsOpen(!modalIsOpen);
|
||||
setIsInUpdate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEdit = (): void => {
|
||||
setIsInEdit(!isInEdit);
|
||||
setIsInUpdate(false);
|
||||
}
|
||||
setShowTable(!showTable);
|
||||
};
|
||||
|
||||
const toggleUpdate = (app: App): void => {
|
||||
setAppInUpdate(app);
|
||||
setIsInUpdate(true);
|
||||
const openFormForUpdating = (app: App): void => {
|
||||
setEditApp(app);
|
||||
setModalIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Modal isOpen={modalIsOpen} setIsOpen={setModalIsOpen}>
|
||||
{!isInUpdate
|
||||
? <AppForm modalHandler={toggleModal} />
|
||||
: <AppForm modalHandler={toggleModal} app={appInUpdate} />
|
||||
}
|
||||
<AppForm modalHandler={toggleModal} />
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAuthenticated && (
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton
|
||||
name="Add"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => {
|
||||
setEditApp(null);
|
||||
toggleModal();
|
||||
}}
|
||||
/>
|
||||
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.Apps}>
|
||||
{props.loading
|
||||
? <Spinner />
|
||||
: (!isInEdit
|
||||
? <AppGrid apps={props.apps} />
|
||||
: <AppTable updateAppHandler={toggleUpdate} />)
|
||||
}
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : !showTable ? (
|
||||
<AppGrid apps={apps} searching={props.searching} />
|
||||
) : (
|
||||
<AppTable openFormForUpdating={openFormForUpdating} />
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
apps: state.app.apps,
|
||||
loading: state.app.loading
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { getApps })(Apps);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,6 +10,10 @@
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.BookmarkHeader:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Bookmarks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -18,9 +22,35 @@
|
|||
.Bookmarks a {
|
||||
line-height: 2;
|
||||
transition: all 0.25s;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.BookmarkCard a:hover {
|
||||
text-decoration: underline;
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.BookmarkIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
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,26 +1,105 @@
|
|||
import { Bookmark, Category } from '../../../interfaces';
|
||||
import classes from './BookmarkCard.module.css';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
interface ComponentProps {
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
|
||||
// Other
|
||||
import classes from './BookmarkCard.module.css';
|
||||
import { Icon } from '../../UI';
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
|
||||
interface Props {
|
||||
category: Category;
|
||||
fromHomepage?: boolean;
|
||||
}
|
||||
|
||||
const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
||||
export const BookmarkCard = (props: Props): JSX.Element => {
|
||||
const { category, fromHomepage = false } = props;
|
||||
|
||||
const {
|
||||
config: { config },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { setEditCategory } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
return (
|
||||
<div className={classes.BookmarkCard}>
|
||||
<h3>{props.category.name}</h3>
|
||||
<h3
|
||||
className={
|
||||
fromHomepage || !isAuthenticated ? '' : classes.BookmarkHeader
|
||||
}
|
||||
onClick={() => {
|
||||
if (!fromHomepage && isAuthenticated) {
|
||||
setEditCategory(category);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</h3>
|
||||
|
||||
<div className={classes.Bookmarks}>
|
||||
{props.category.bookmarks.map((bookmark: Bookmark) => (
|
||||
<a
|
||||
href={`http://${bookmark.url}`}
|
||||
target='blank'
|
||||
key={`bookmark-${bookmark.id}`}>
|
||||
{bookmark.name}
|
||||
</a>
|
||||
))}
|
||||
{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 (isImage(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.BookmarkIcon}>
|
||||
<img
|
||||
src={source}
|
||||
alt={`${name} icon`}
|
||||
className={classes.CustomIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (isSvg(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.BookmarkIcon}>
|
||||
<svg
|
||||
data-src={source}
|
||||
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={config.bookmarksSameTab ? '' : '_blank'}
|
||||
rel="noreferrer"
|
||||
key={`bookmark-${bookmark.id}`}
|
||||
>
|
||||
{bookmark.icon && iconEl}
|
||||
{bookmark.name}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BookmarkCard;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,226 +0,0 @@
|
|||
import { useState, SyntheticEvent, Fragment, ChangeEvent, useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
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';
|
||||
|
||||
interface ComponentProps {
|
||||
modalHandler: () => void;
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
category?: Category;
|
||||
bookmark?: Bookmark;
|
||||
addCategory: (formData: NewCategory) => void;
|
||||
addBookmark: (formData: NewBookmark) => void;
|
||||
updateCategory: (id: number, formData: NewCategory) => void;
|
||||
updateBookmark: (id: number, formData: NewBookmark, previousCategoryId: number) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
|
||||
const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
||||
const [categoryName, setCategoryName] = useState<NewCategory>({
|
||||
name: ''
|
||||
})
|
||||
|
||||
const [formData, setFormData] = useState<NewBookmark>({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: -1
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (props.category) {
|
||||
setCategoryName({ name: props.category.name });
|
||||
} else {
|
||||
setCategoryName({ name: '' });
|
||||
}
|
||||
}, [props.category])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.bookmark) {
|
||||
setFormData({
|
||||
name: props.bookmark.name,
|
||||
url: props.bookmark.url,
|
||||
categoryId: props.bookmark.categoryId
|
||||
})
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: -1
|
||||
})
|
||||
}
|
||||
}, [props.bookmark])
|
||||
|
||||
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!props.category && !props.bookmark) {
|
||||
// Add new
|
||||
if (props.contentType === ContentType.category) {
|
||||
// Add category
|
||||
props.addCategory(categoryName);
|
||||
setCategoryName({ name: '' });
|
||||
} else if (props.contentType === ContentType.bookmark) {
|
||||
// Add bookmark
|
||||
if (formData.categoryId === -1) {
|
||||
props.createNotification({
|
||||
title: 'Error',
|
||||
message: 'Please select category'
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
props.addBookmark(formData);
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: formData.categoryId
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Update
|
||||
if (props.contentType === ContentType.category && props.category) {
|
||||
// Update category
|
||||
props.updateCategory(props.category.id, categoryName);
|
||||
setCategoryName({ name: '' });
|
||||
} else if (props.contentType === ContentType.bookmark && props.bookmark) {
|
||||
// Update bookmark
|
||||
props.updateBookmark(props.bookmark.id, formData, props.bookmark.categoryId);
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: -1
|
||||
})
|
||||
}
|
||||
|
||||
props.modalHandler();
|
||||
}
|
||||
}
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
|
||||
setFormData({
|
||||
...formData,
|
||||
categoryId: parseInt(e.target.value)
|
||||
})
|
||||
}
|
||||
|
||||
let button = <Button>Submit</Button>
|
||||
|
||||
if (!props.category && !props.bookmark) {
|
||||
if (props.contentType === ContentType.category) {
|
||||
button = <Button>Add new category</Button>;
|
||||
} else {
|
||||
button = <Button>Add new bookmark</Button>;
|
||||
}
|
||||
} else if (props.category) {
|
||||
button = <Button>Update category</Button>
|
||||
} else if (props.bookmark) {
|
||||
button = <Button>Update bookmark</Button>
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
modalHandler={props.modalHandler}
|
||||
formHandler={formSubmitHandler}
|
||||
>
|
||||
{props.contentType === ContentType.category
|
||||
? (
|
||||
<Fragment>
|
||||
<InputGroup>
|
||||
<label htmlFor='categoryName'>Category Name</label>
|
||||
<input
|
||||
type='text'
|
||||
name='categoryName'
|
||||
id='categoryName'
|
||||
placeholder='Social Media'
|
||||
required
|
||||
value={categoryName.name}
|
||||
onChange={(e) => setCategoryName({ name: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Fragment>
|
||||
)
|
||||
: (
|
||||
<Fragment>
|
||||
<InputGroup>
|
||||
<label htmlFor='name'>Bookmark Name</label>
|
||||
<input
|
||||
type='text'
|
||||
name='name'
|
||||
id='name'
|
||||
placeholder='Reddit'
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor='url'>Bookmark URL</label>
|
||||
<input
|
||||
type='text'
|
||||
name='url'
|
||||
id='url'
|
||||
placeholder='reddit.com'
|
||||
required
|
||||
value={formData.url}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</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>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
{button}
|
||||
</ModalForm>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
categories: state.bookmark.categories
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchMap = {
|
||||
getCategories,
|
||||
addCategory,
|
||||
addBookmark,
|
||||
updateCategory,
|
||||
updateBookmark,
|
||||
createNotification
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);
|
|
@ -20,12 +20,3 @@
|
|||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.BookmarksMessage {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.BookmarksMessage a {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
|
@ -2,30 +2,63 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import classes from './BookmarkGrid.module.css';
|
||||
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
import { Category } from '../../../interfaces';
|
||||
|
||||
import BookmarkCard from '../BookmarkCard/BookmarkCard';
|
||||
import { BookmarkCard } from '../BookmarkCard/BookmarkCard';
|
||||
import { Message } from '../../UI';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
totalCategories?: number;
|
||||
searching: boolean;
|
||||
fromHomepage?: boolean;
|
||||
}
|
||||
|
||||
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
|
||||
export const BookmarkGrid = (props: Props): JSX.Element => {
|
||||
const {
|
||||
categories,
|
||||
totalCategories,
|
||||
searching,
|
||||
fromHomepage = false,
|
||||
} = props;
|
||||
|
||||
let bookmarks: JSX.Element;
|
||||
|
||||
if (props.categories.length > 0) {
|
||||
bookmarks = (
|
||||
<div className={classes.BookmarkGrid}>
|
||||
{props.categories.map((category: Category): JSX.Element => <BookmarkCard category={category} key={category.id} />)}
|
||||
</div>
|
||||
);
|
||||
if (categories.length) {
|
||||
if (searching && !categories[0].bookmarks.length) {
|
||||
bookmarks = <Message>No bookmarks match your search criteria</Message>;
|
||||
} else {
|
||||
bookmarks = (
|
||||
<div className={classes.BookmarkGrid}>
|
||||
{categories.map(
|
||||
(category: Category): JSX.Element => (
|
||||
<BookmarkCard
|
||||
category={category}
|
||||
fromHomepage={fromHomepage}
|
||||
key={category.id}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} 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>
|
||||
);
|
||||
if (totalCategories) {
|
||||
bookmarks = (
|
||||
<Message>
|
||||
There are no pinned categories. You can pin them from the{' '}
|
||||
<Link to="/bookmarks">/bookmarks</Link> menu
|
||||
</Message>
|
||||
);
|
||||
} else {
|
||||
bookmarks = (
|
||||
<Message>
|
||||
You don't have any bookmarks. You can add a new one from{' '}
|
||||
<Link to="/bookmarks">/bookmarks</Link> menu
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
export default BookmarkGrid;
|
||||
};
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
.TableActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.TableAction {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.TableAction:hover {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
import { ContentType } from '../Bookmarks';
|
||||
import classes from './BookmarkTable.module.css';
|
||||
import { connect } from 'react-redux';
|
||||
import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions';
|
||||
import { KeyboardEvent } from 'react';
|
||||
|
||||
import Table from '../../UI/Table/Table';
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
|
||||
interface ComponentProps {
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
pinCategory: (category: Category) => void;
|
||||
deleteCategory: (id: number) => void;
|
||||
updateHandler: (data: Category | Bookmark) => void;
|
||||
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
||||
}
|
||||
|
||||
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
||||
const deleteCategoryHandler = (category: Category): void => {
|
||||
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
|
||||
|
||||
if (proceed) {
|
||||
props.deleteCategory(category.id);
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBookmarkHandler = (bookmark: Bookmark): void => {
|
||||
const proceed = window.confirm(`Are you sure you want to delete ${bookmark.name}?`);
|
||||
|
||||
if (proceed) {
|
||||
props.deleteBookmark(bookmark.id, bookmark.categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
const keyboardActionHandler = (e: KeyboardEvent, category: Category, handler: Function) => {
|
||||
if (e.key === 'Enter') {
|
||||
handler(category);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.contentType === ContentType.category) {
|
||||
return (
|
||||
<Table headers={[
|
||||
'Name',
|
||||
'Actions'
|
||||
]}>
|
||||
{props.categories.map((category: Category) => {
|
||||
return (
|
||||
<tr key={category.id}>
|
||||
<td>{category.name}</td>
|
||||
<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)}
|
||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</Table>
|
||||
)
|
||||
} else {
|
||||
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
|
||||
props.categories.forEach((category: Category) => {
|
||||
category.bookmarks.forEach((bookmark: Bookmark) => {
|
||||
bookmarks.push({
|
||||
bookmark,
|
||||
categoryName: category.name
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Table headers={[
|
||||
'Name',
|
||||
'URL',
|
||||
'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.categoryName}</td>
|
||||
<td className={classes.TableActions}>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
||||
// onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
||||
tabIndex={0}>
|
||||
<Icon icon='mdiDelete' />
|
||||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.updateHandler(bookmark.bookmark)}
|
||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
||||
tabIndex={0}>
|
||||
<Icon icon='mdiPencil' />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null, { pinCategory, deleteCategory, deleteBookmark })(BookmarkTable);
|
|
@ -1,156 +1,195 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import { getCategories } from '../../store/actions';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
// Typescript
|
||||
import { Category, Bookmark } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './Bookmarks.module.css';
|
||||
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
|
||||
// UI
|
||||
import {
|
||||
Container,
|
||||
Headline,
|
||||
ActionButton,
|
||||
Spinner,
|
||||
Modal,
|
||||
Message,
|
||||
} from '../UI';
|
||||
|
||||
import BookmarkGrid from './BookmarkGrid/BookmarkGrid';
|
||||
import { Category, GlobalState, Bookmark } from '../../interfaces';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import Modal from '../UI/Modal/Modal';
|
||||
import BookmarkForm from './BookmarkForm/BookmarkForm';
|
||||
import BookmarkTable from './BookmarkTable/BookmarkTable';
|
||||
// Components
|
||||
import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
|
||||
import { Form } from './Form/Form';
|
||||
import { Table } from './Table/Table';
|
||||
|
||||
interface ComponentProps {
|
||||
loading: boolean;
|
||||
categories: Category[];
|
||||
getCategories: () => void;
|
||||
interface Props {
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
export enum ContentType {
|
||||
category,
|
||||
bookmark
|
||||
bookmark,
|
||||
}
|
||||
|
||||
const Bookmarks = (props: ComponentProps): JSX.Element => {
|
||||
export const Bookmarks = (props: Props): JSX.Element => {
|
||||
// Get Redux state
|
||||
const {
|
||||
bookmarks: { loading, categories, categoryInEdit },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
// Get Redux action creators
|
||||
const dispatch = useDispatch();
|
||||
const { getCategories, setEditCategory, setEditBookmark } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
// Load categories if array is empty
|
||||
useEffect(() => {
|
||||
if (!categories.length) {
|
||||
getCategories();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Form
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [formContentType, setFormContentType] = useState(ContentType.category);
|
||||
const [isInEdit, setIsInEdit] = useState(false);
|
||||
const [tableContentType, setTableContentType] = useState(ContentType.category);
|
||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
|
||||
name: '',
|
||||
id: -1,
|
||||
isPinned: false,
|
||||
bookmarks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: -1,
|
||||
id: -1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
|
||||
// Table
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
const [tableContentType, setTableContentType] = useState(
|
||||
ContentType.category
|
||||
);
|
||||
|
||||
// Observe if user is authenticated -> set default view (grid) if not
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setShowTable(false);
|
||||
setModalIsOpen(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.categories.length === 0) {
|
||||
props.getCategories();
|
||||
if (categoryInEdit && !modalIsOpen) {
|
||||
setTableContentType(ContentType.bookmark);
|
||||
setShowTable(true);
|
||||
}
|
||||
}, [props.getCategories])
|
||||
}, [categoryInEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
}, []);
|
||||
|
||||
// Form actions
|
||||
const toggleModal = (): void => {
|
||||
setModalIsOpen(!modalIsOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const addActionHandler = (contentType: ContentType) => {
|
||||
const openFormForAdding = (contentType: ContentType) => {
|
||||
setFormContentType(contentType);
|
||||
setIsInUpdate(false);
|
||||
toggleModal();
|
||||
}
|
||||
};
|
||||
|
||||
const editActionHandler = (contentType: ContentType) => {
|
||||
// We're in the edit mode and the same button was clicked - go back to list
|
||||
if (isInEdit && contentType === tableContentType) {
|
||||
setIsInEdit(false);
|
||||
} else {
|
||||
setIsInEdit(true);
|
||||
setTableContentType(contentType);
|
||||
}
|
||||
}
|
||||
|
||||
const instanceOfCategory = (object: any): object is Category => {
|
||||
return 'bookmarks' in object;
|
||||
}
|
||||
|
||||
const goToUpdateMode = (data: Category | Bookmark): void => {
|
||||
const openFormForUpdating = (data: Category | Bookmark): void => {
|
||||
setIsInUpdate(true);
|
||||
|
||||
const instanceOfCategory = (object: any): object is Category => {
|
||||
return 'bookmarks' in object;
|
||||
};
|
||||
|
||||
if (instanceOfCategory(data)) {
|
||||
setFormContentType(ContentType.category);
|
||||
setCategoryInUpdate(data);
|
||||
setEditCategory(data);
|
||||
} else {
|
||||
setFormContentType(ContentType.bookmark);
|
||||
setBookmarkInUpdate(data);
|
||||
setEditBookmark(data);
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
}
|
||||
};
|
||||
|
||||
// Table actions
|
||||
const showTableForEditing = (contentType: ContentType) => {
|
||||
// We're in the edit mode and the same button was clicked - go back to list
|
||||
if (showTable && contentType === tableContentType) {
|
||||
setEditCategory(null);
|
||||
setShowTable(false);
|
||||
} else {
|
||||
setShowTable(true);
|
||||
setTableContentType(contentType);
|
||||
}
|
||||
};
|
||||
|
||||
const finishEditing = () => {
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
};
|
||||
|
||||
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} />
|
||||
}
|
||||
<Form
|
||||
modalHandler={toggleModal}
|
||||
contentType={formContentType}
|
||||
inUpdate={isInUpdate}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Headline
|
||||
title='All Bookmarks'
|
||||
subtitle={(<Link to='/'>Go back</Link>)}
|
||||
/>
|
||||
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton
|
||||
name='Add Category'
|
||||
icon='mdiPlusBox'
|
||||
handler={() => addActionHandler(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name='Add Bookmark'
|
||||
icon='mdiPlusBox'
|
||||
handler={() => addActionHandler(ContentType.bookmark)}
|
||||
/>
|
||||
<ActionButton
|
||||
name='Edit Categories'
|
||||
icon='mdiPencil'
|
||||
handler={() => editActionHandler(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name='Edit Bookmarks'
|
||||
icon='mdiPencil'
|
||||
handler={() => editActionHandler(ContentType.bookmark)}
|
||||
/>
|
||||
</div>
|
||||
<Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
|
||||
|
||||
{props.loading
|
||||
? <Spinner />
|
||||
: (!isInEdit
|
||||
? <BookmarkGrid categories={props.categories} />
|
||||
: <BookmarkTable
|
||||
contentType={tableContentType}
|
||||
categories={props.categories}
|
||||
updateHandler={goToUpdateMode}
|
||||
{isAuthenticated && (
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton
|
||||
name="Add Category"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => openFormForAdding(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Add Bookmark"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => openFormForAdding(ContentType.bookmark)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Edit Categories"
|
||||
icon="mdiPencil"
|
||||
handler={() => showTableForEditing(ContentType.category)}
|
||||
/>
|
||||
{showTable && tableContentType === ContentType.bookmark && (
|
||||
<ActionButton
|
||||
name="Finish Editing"
|
||||
icon="mdiPencil"
|
||||
handler={finishEditing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length && isAuthenticated && !showTable ? (
|
||||
<Message isPrimary={false}>
|
||||
Click on category name to edit its bookmarks
|
||||
</Message>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : !showTable ? (
|
||||
<BookmarkGrid categories={categories} searching={props.searching} />
|
||||
) : (
|
||||
<Table
|
||||
contentType={tableContentType}
|
||||
openFormForUpdating={openFormForUpdating}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
loading: state.bookmark.loading,
|
||||
categories: state.bookmark.categories
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { getCategories })(Bookmarks);
|
||||
);
|
||||
};
|
||||
|
|
275
client/src/components/Bookmarks/Form/BookmarksForm.tsx
Normal file
|
@ -0,0 +1,275 @@
|
|||
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category, NewBookmark } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
|
||||
// CSS
|
||||
import classes from './Form.module.css';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, newBookmarkTemplate } from '../../../utility';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
bookmark?: Bookmark;
|
||||
}
|
||||
|
||||
export const BookmarksForm = ({
|
||||
bookmark,
|
||||
modalHandler,
|
||||
}: Props): JSX.Element => {
|
||||
const { categories } = useSelector((state: State) => state.bookmarks);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { addBookmark, updateBookmark, createNotification } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<NewBookmark>(newBookmarkTemplate);
|
||||
|
||||
// Load bookmark data if provided for editing
|
||||
useEffect(() => {
|
||||
if (bookmark) {
|
||||
setFormData({ ...bookmark });
|
||||
} else {
|
||||
setFormData(newBookmarkTemplate);
|
||||
}
|
||||
}, [bookmark]);
|
||||
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<NewBookmark>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.target.files) {
|
||||
setCustomIcon(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Bookmarks form handler
|
||||
const formSubmitHandler = (e: FormEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
for (let field of ['name', 'url', 'icon'] as const) {
|
||||
if (/^ +$/.test(formData[field])) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: `Field cannot be empty: ${field}`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const createFormData = (): FormData => {
|
||||
const data = new FormData();
|
||||
if (customIcon) {
|
||||
data.append('icon', customIcon);
|
||||
}
|
||||
data.append('name', formData.name);
|
||||
data.append('url', formData.url);
|
||||
data.append('categoryId', `${formData.categoryId}`);
|
||||
data.append('isPublic', `${formData.isPublic ? 1 : 0}`);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const checkCategory = (): boolean => {
|
||||
if (formData.categoryId < 0) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Please select category',
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!bookmark) {
|
||||
// add new bookmark
|
||||
if (!checkCategory()) return;
|
||||
|
||||
if (formData.categoryId < 0) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Please select category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
addBookmark(data);
|
||||
} else {
|
||||
addBookmark(formData);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...newBookmarkTemplate,
|
||||
categoryId: formData.categoryId,
|
||||
isPublic: formData.isPublic,
|
||||
});
|
||||
} else {
|
||||
// update
|
||||
if (!checkCategory()) return;
|
||||
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
updateBookmark(bookmark.id, data, {
|
||||
prev: bookmark.categoryId,
|
||||
curr: formData.categoryId,
|
||||
});
|
||||
} else {
|
||||
updateBookmark(bookmark.id, formData, {
|
||||
prev: bookmark.categoryId,
|
||||
curr: formData.categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
modalHandler();
|
||||
}
|
||||
|
||||
setFormData({ ...newBookmarkTemplate, categoryId: formData.categoryId });
|
||||
setCustomIcon(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
|
||||
{/* NAME */}
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Bookmark Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Reddit"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* URL */}
|
||||
<InputGroup>
|
||||
<label htmlFor="url">Bookmark URL</label>
|
||||
<input
|
||||
type="text"
|
||||
name="url"
|
||||
id="url"
|
||||
placeholder="reddit.com"
|
||||
required
|
||||
value={formData.url}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* CATEGORY */}
|
||||
<InputGroup>
|
||||
<label htmlFor="categoryId">Bookmark Category</label>
|
||||
<select
|
||||
name="categoryId"
|
||||
id="categoryId"
|
||||
required
|
||||
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
|
||||
value={formData.categoryId}
|
||||
>
|
||||
<option value={-1}>Select category</option>
|
||||
{categories.map((category: Category): JSX.Element => {
|
||||
return (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* ICON */}
|
||||
{!useCustomIcon ? (
|
||||
// mdi
|
||||
<InputGroup>
|
||||
<label htmlFor="icon">Bookmark Icon (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="icon"
|
||||
id="icon"
|
||||
placeholder="book-open-outline"
|
||||
value={formData.icon}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
<span>
|
||||
Use icon name from MDI or pass a valid URL.
|
||||
<a href="https://materialdesignicons.com/" target="blank">
|
||||
{' '}
|
||||
Click here for reference
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||
className={classes.Switch}
|
||||
>
|
||||
Switch to custom icon upload
|
||||
</span>
|
||||
</InputGroup>
|
||||
) : (
|
||||
// custom
|
||||
<InputGroup>
|
||||
<label htmlFor="icon">Bookmark Icon (optional)</label>
|
||||
<input
|
||||
type="file"
|
||||
name="icon"
|
||||
id="icon"
|
||||
onChange={(e) => fileChangeHandler(e)}
|
||||
accept=".jpg,.jpeg,.png,.svg,.ico"
|
||||
/>
|
||||
<span
|
||||
onClick={() => {
|
||||
setCustomIcon(null);
|
||||
toggleUseCustomIcon(!useCustomIcon);
|
||||
}}
|
||||
className={classes.Switch}
|
||||
>
|
||||
Switch to MDI
|
||||
</span>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
{/* VISIBILTY */}
|
||||
<InputGroup>
|
||||
<label htmlFor="isPublic">Bookmark visibility</label>
|
||||
<select
|
||||
id="isPublic"
|
||||
name="isPublic"
|
||||
value={formData.isPublic ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>Visible (anyone can access it)</option>
|
||||
<option value={0}>Hidden (authentication required)</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>{bookmark ? 'Update bookmark' : 'Add new bookmark'}</Button>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
100
client/src/components/Bookmarks/Form/CategoryForm.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Category, NewCategory } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, newCategoryTemplate } from '../../../utility';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
category?: Category;
|
||||
}
|
||||
|
||||
export const CategoryForm = ({
|
||||
category,
|
||||
modalHandler,
|
||||
}: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { addCategory, updateCategory } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const [formData, setFormData] = useState<NewCategory>(newCategoryTemplate);
|
||||
|
||||
// Load category data if provided for editing
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setFormData({ ...category });
|
||||
} else {
|
||||
setFormData(newCategoryTemplate);
|
||||
}
|
||||
}, [category]);
|
||||
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<NewCategory>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
// Category form handler
|
||||
const formSubmitHandler = (e: FormEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!category) {
|
||||
addCategory(formData);
|
||||
} else {
|
||||
updateCategory(category.id, formData);
|
||||
modalHandler();
|
||||
}
|
||||
|
||||
setFormData(newCategoryTemplate);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Category Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Social Media"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="isPublic">Category visibility</label>
|
||||
<select
|
||||
id="isPublic"
|
||||
name="isPublic"
|
||||
value={formData.isPublic ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>Visible (anyone can access it)</option>
|
||||
<option value={0}>Hidden (authentication required)</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>{category ? 'Update category' : 'Add new category'}</Button>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
7
client/src/components/Bookmarks/Form/Form.module.css
Normal file
|
@ -0,0 +1,7 @@
|
|||
.Switch {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.Switch:hover {
|
||||
cursor: pointer;
|
||||
}
|
54
client/src/components/Bookmarks/Form/Form.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
// Typescript
|
||||
import { ContentType } from '../Bookmarks';
|
||||
|
||||
// Utils
|
||||
import { CategoryForm } from './CategoryForm';
|
||||
import { BookmarksForm } from './BookmarksForm';
|
||||
import { Fragment } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bookmarkTemplate, categoryTemplate } from '../../../utility';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
contentType: ContentType;
|
||||
inUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const Form = (props: Props): JSX.Element => {
|
||||
const { categoryInEdit, bookmarkInEdit } = useSelector(
|
||||
(state: State) => state.bookmarks
|
||||
);
|
||||
|
||||
const { modalHandler, contentType, inUpdate } = props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{!inUpdate ? (
|
||||
// form: add new
|
||||
<Fragment>
|
||||
{contentType === ContentType.category ? (
|
||||
<CategoryForm modalHandler={modalHandler} />
|
||||
) : (
|
||||
<BookmarksForm modalHandler={modalHandler} />
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
// form: update
|
||||
<Fragment>
|
||||
{contentType === ContentType.category ? (
|
||||
<CategoryForm
|
||||
modalHandler={modalHandler}
|
||||
category={categoryInEdit || categoryTemplate}
|
||||
/>
|
||||
) : (
|
||||
<BookmarksForm
|
||||
modalHandler={modalHandler}
|
||||
bookmark={bookmarkInEdit || bookmarkTemplate}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
188
client/src/components/Bookmarks/Table/BookmarksTable.tsx
Normal file
|
@ -0,0 +1,188 @@
|
|||
import { useState, useEffect, Fragment } from 'react';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { Message, Table } from '../../UI';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import { bookmarkTemplate } from '../../../utility';
|
||||
|
||||
interface Props {
|
||||
openFormForUpdating: (data: Category | Bookmark) => void;
|
||||
}
|
||||
|
||||
export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
||||
const {
|
||||
bookmarks: { categoryInEdit },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
deleteBookmark,
|
||||
updateBookmark,
|
||||
createNotification,
|
||||
reorderBookmarks,
|
||||
} = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]);
|
||||
|
||||
// Copy bookmarks array
|
||||
useEffect(() => {
|
||||
if (categoryInEdit) {
|
||||
setLocalBookmarks([...categoryInEdit.bookmarks]);
|
||||
}
|
||||
}, [categoryInEdit]);
|
||||
|
||||
// Drag and drop handler
|
||||
const dragEndHanlder = (result: DropResult): void => {
|
||||
if (config.useOrdering !== 'orderId') {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Custom order is disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpBookmarks = [...localBookmarks];
|
||||
const [movedBookmark] = tmpBookmarks.splice(result.source.index, 1);
|
||||
tmpBookmarks.splice(result.destination.index, 0, movedBookmark);
|
||||
|
||||
setLocalBookmarks(tmpBookmarks);
|
||||
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
reorderBookmarks(tmpBookmarks, categoryId);
|
||||
};
|
||||
|
||||
// Action hanlders
|
||||
const deleteBookmarkHandler = (id: number, name: string) => {
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
|
||||
const proceed = window.confirm(`Are you sure you want to delete ${name}?`);
|
||||
if (proceed) {
|
||||
deleteBookmark(id, categoryId);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBookmarkHandler = (id: number) => {
|
||||
const bookmark =
|
||||
categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate;
|
||||
|
||||
openFormForUpdating(bookmark);
|
||||
};
|
||||
|
||||
const changeBookmarkVisibiltyHandler = (id: number) => {
|
||||
const bookmark =
|
||||
categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate;
|
||||
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
const [prev, curr] = [categoryId, categoryId];
|
||||
|
||||
updateBookmark(
|
||||
id,
|
||||
{ ...bookmark, isPublic: !bookmark.isPublic },
|
||||
{ prev, curr }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{!categoryInEdit ? (
|
||||
<Message isPrimary={false}>
|
||||
Switch to grid view and click on the name of category you want to edit
|
||||
</Message>
|
||||
) : (
|
||||
<Message isPrimary={false}>
|
||||
Editing bookmarks from <span>{categoryInEdit.name}</span>
|
||||
category
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{categoryInEdit && (
|
||||
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||
<Droppable droppableId="bookmarks">
|
||||
{(provided) => (
|
||||
<Table
|
||||
headers={[
|
||||
'Name',
|
||||
'URL',
|
||||
'Icon',
|
||||
'Visibility',
|
||||
'Category',
|
||||
'Actions',
|
||||
]}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{localBookmarks.map((bookmark, index): JSX.Element => {
|
||||
return (
|
||||
<Draggable
|
||||
key={bookmark.id}
|
||||
draggableId={bookmark.id.toString()}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => {
|
||||
const style = {
|
||||
border: snapshot.isDragging
|
||||
? '1px solid var(--color-accent)'
|
||||
: 'none',
|
||||
borderRadius: '4px',
|
||||
...provided.draggableProps.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
style={style}
|
||||
>
|
||||
<td style={{ width: '200px' }}>{bookmark.name}</td>
|
||||
<td style={{ width: '200px' }}>{bookmark.url}</td>
|
||||
<td style={{ width: '200px' }}>{bookmark.icon}</td>
|
||||
<td style={{ width: '200px' }}>
|
||||
{bookmark.isPublic ? 'Visible' : 'Hidden'}
|
||||
</td>
|
||||
<td style={{ width: '200px' }}>
|
||||
{categoryInEdit.name}
|
||||
</td>
|
||||
|
||||
{!snapshot.isDragging && (
|
||||
<TableActions
|
||||
entity={bookmark}
|
||||
deleteHandler={deleteBookmarkHandler}
|
||||
updateHandler={updateBookmarkHandler}
|
||||
changeVisibilty={changeBookmarkVisibiltyHandler}
|
||||
showPin={false}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
166
client/src/components/Bookmarks/Table/CategoryTable.tsx
Normal file
|
@ -0,0 +1,166 @@
|
|||
import { useState, useEffect, Fragment } from 'react';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { Message, Table } from '../../UI';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
|
||||
interface Props {
|
||||
openFormForUpdating: (data: Category | Bookmark) => void;
|
||||
}
|
||||
|
||||
export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
||||
const {
|
||||
config: { config },
|
||||
bookmarks: { categories },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
pinCategory,
|
||||
deleteCategory,
|
||||
createNotification,
|
||||
reorderCategories,
|
||||
updateCategory,
|
||||
} = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [localCategories, setLocalCategories] = useState<Category[]>([]);
|
||||
|
||||
// Copy categories array
|
||||
useEffect(() => {
|
||||
setLocalCategories([...categories]);
|
||||
}, [categories]);
|
||||
|
||||
// Drag and drop handler
|
||||
const dragEndHanlder = (result: DropResult): void => {
|
||||
if (config.useOrdering !== 'orderId') {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Custom order is disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpCategories = [...localCategories];
|
||||
const [movedCategory] = tmpCategories.splice(result.source.index, 1);
|
||||
tmpCategories.splice(result.destination.index, 0, movedCategory);
|
||||
|
||||
setLocalCategories(tmpCategories);
|
||||
reorderCategories(tmpCategories);
|
||||
};
|
||||
|
||||
// Action handlers
|
||||
const deleteCategoryHandler = (id: number, name: string) => {
|
||||
const proceed = window.confirm(
|
||||
`Are you sure you want to delete ${name}? It will delete ALL assigned bookmarks`
|
||||
);
|
||||
|
||||
if (proceed) {
|
||||
deleteCategory(id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCategoryHandler = (id: number) => {
|
||||
const category = categories.find((c) => c.id === id) as Category;
|
||||
openFormForUpdating(category);
|
||||
};
|
||||
|
||||
const pinCategoryHandler = (id: number) => {
|
||||
const category = categories.find((c) => c.id === id) as Category;
|
||||
pinCategory(category);
|
||||
};
|
||||
|
||||
const changeCategoryVisibiltyHandler = (id: number) => {
|
||||
const category = categories.find((c) => c.id === id) as Category;
|
||||
updateCategory(id, { ...category, isPublic: !category.isPublic });
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Message isPrimary={false}>
|
||||
{config.useOrdering === 'orderId' ? (
|
||||
<p>You can drag and drop single rows to reorder categories</p>
|
||||
) : (
|
||||
<p>
|
||||
Custom order is disabled. You can change it in the{' '}
|
||||
<Link to="/settings/general">settings</Link>
|
||||
</p>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||
<Droppable droppableId="categories">
|
||||
{(provided) => (
|
||||
<Table
|
||||
headers={['Name', 'Visibility', 'Actions']}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{localCategories.map((category, index): JSX.Element => {
|
||||
return (
|
||||
<Draggable
|
||||
key={category.id}
|
||||
draggableId={category.id.toString()}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => {
|
||||
const style = {
|
||||
border: snapshot.isDragging
|
||||
? '1px solid var(--color-accent)'
|
||||
: 'none',
|
||||
borderRadius: '4px',
|
||||
...provided.draggableProps.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
style={style}
|
||||
>
|
||||
<td style={{ width: '300px' }}>{category.name}</td>
|
||||
<td style={{ width: '300px' }}>
|
||||
{category.isPublic ? 'Visible' : 'Hidden'}
|
||||
</td>
|
||||
|
||||
{!snapshot.isDragging && (
|
||||
<TableActions
|
||||
entity={category}
|
||||
deleteHandler={deleteCategoryHandler}
|
||||
updateHandler={updateCategoryHandler}
|
||||
pinHanlder={pinCategoryHandler}
|
||||
changeVisibilty={changeCategoryVisibiltyHandler}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
20
client/src/components/Bookmarks/Table/Table.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { Category, Bookmark } from '../../../interfaces';
|
||||
import { ContentType } from '../Bookmarks';
|
||||
import { BookmarksTable } from './BookmarksTable';
|
||||
import { CategoryTable } from './CategoryTable';
|
||||
|
||||
interface Props {
|
||||
contentType: ContentType;
|
||||
openFormForUpdating: (data: Category | Bookmark) => void;
|
||||
}
|
||||
|
||||
export const Table = (props: Props): JSX.Element => {
|
||||
const tableEl =
|
||||
props.contentType === ContentType.category ? (
|
||||
<CategoryTable openFormForUpdating={props.openFormForUpdating} />
|
||||
) : (
|
||||
<BookmarksTable openFormForUpdating={props.openFormForUpdating} />
|
||||
);
|
||||
|
||||
return tableEl;
|
||||
};
|
31
client/src/components/Home/Header/Header.module.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
.Header h1 {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 4em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Header p {
|
||||
color: var(--color-primary);
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.HeaderMain {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.SettingsLink {
|
||||
visibility: visible;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.SettingsLink {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
53
client/src/components/Home/Header/Header.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// CSS
|
||||
import classes from './Header.module.css';
|
||||
|
||||
// Components
|
||||
import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
|
||||
|
||||
// Utils
|
||||
import { getDateTime } from './functions/getDateTime';
|
||||
import { greeter } from './functions/greeter';
|
||||
|
||||
export const Header = (): JSX.Element => {
|
||||
const { hideHeader, hideDate, showTime } = useSelector(
|
||||
(state: State) => state.config.config
|
||||
);
|
||||
|
||||
const [dateTime, setDateTime] = useState<string>(getDateTime());
|
||||
const [greeting, setGreeting] = useState<string>(greeter());
|
||||
|
||||
useEffect(() => {
|
||||
let dateTimeInterval: NodeJS.Timeout;
|
||||
|
||||
dateTimeInterval = setInterval(() => {
|
||||
setDateTime(getDateTime());
|
||||
setGreeting(greeter());
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(dateTimeInterval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className={classes.Header}>
|
||||
{(!hideDate || showTime) && <p>{dateTime}</p>}
|
||||
|
||||
<Link to="/settings" className={classes.SettingsLink}>
|
||||
Go to Settings
|
||||
</Link>
|
||||
|
||||
{!hideHeader && (
|
||||
<span className={classes.HeaderMain}>
|
||||
<h1>{greeting}</h1>
|
||||
<WeatherWidget />
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
71
client/src/components/Home/Header/functions/getDateTime.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { parseTime } from '../../../../utility';
|
||||
|
||||
export const getDateTime = (): string => {
|
||||
const days = localStorage.getItem('daySchema')?.split(';') || [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
const months = localStorage.getItem('monthSchema')?.split(';') || [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const useAmericanDate = localStorage.useAmericanDate === 'true';
|
||||
const showTime = localStorage.showTime === 'true';
|
||||
const hideDate = localStorage.hideDate === 'true';
|
||||
|
||||
// Date
|
||||
let dateEl = '';
|
||||
|
||||
if (!hideDate) {
|
||||
if (!useAmericanDate) {
|
||||
dateEl = `${days[now.getDay()]}, ${now.getDate()} ${
|
||||
months[now.getMonth()]
|
||||
} ${now.getFullYear()}`;
|
||||
} else {
|
||||
dateEl = `${days[now.getDay()]}, ${
|
||||
months[now.getMonth()]
|
||||
} ${now.getDate()} ${now.getFullYear()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Time
|
||||
const p = parseTime;
|
||||
let timeEl = '';
|
||||
|
||||
if (showTime) {
|
||||
const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p(
|
||||
now.getSeconds()
|
||||
)}`;
|
||||
|
||||
timeEl = time;
|
||||
}
|
||||
|
||||
// Separator
|
||||
let separator = '';
|
||||
|
||||
if (!hideDate && showTime) {
|
||||
separator = ' - ';
|
||||
}
|
||||
|
||||
// Output
|
||||
return `${dateEl}${separator}${timeEl}`;
|
||||
};
|
17
client/src/components/Home/Header/functions/greeter.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
export const greeter = (): string => {
|
||||
const now = new Date().getHours();
|
||||
let msg: string;
|
||||
|
||||
const greetingsSchemaRaw =
|
||||
localStorage.getItem('greetingsSchema') ||
|
||||
'Good evening!;Good afternoon!;Good morning!;Good night!';
|
||||
const greetingsSchema = greetingsSchemaRaw.split(';');
|
||||
|
||||
if (now >= 18) msg = greetingsSchema[0];
|
||||
else if (now >= 12) msg = greetingsSchema[1];
|
||||
else if (now >= 6) msg = greetingsSchema[2];
|
||||
else if (now >= 0) msg = greetingsSchema[3];
|
||||
else msg = 'Hello!';
|
||||
|
||||
return msg;
|
||||
};
|
|
@ -1,24 +1,3 @@
|
|||
.Header h1 {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 4em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Header p {
|
||||
color: var(--color-primary);
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.HeaderMain {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.SettingsButton {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
|
@ -40,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,111 +1,169 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { getApps, getCategories } from '../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
// Typescript
|
||||
import { GlobalState } from '../../interfaces/GlobalState';
|
||||
import { App, Category } from '../../interfaces';
|
||||
|
||||
// UI
|
||||
import Icon from '../UI/Icons/Icon/Icon';
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import { Icon, Container, SectionHeadline, Spinner, Message } from '../UI';
|
||||
|
||||
// CSS
|
||||
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 { AppGrid } from '../Apps/AppGrid/AppGrid';
|
||||
import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||
import { SearchBar } from '../SearchBar/SearchBar';
|
||||
import { Header } from './Header/Header';
|
||||
|
||||
interface ComponentProps {
|
||||
getApps: Function;
|
||||
getCategories: Function;
|
||||
appsLoading: boolean;
|
||||
apps: App[];
|
||||
categoriesLoading: boolean;
|
||||
categories: Category[];
|
||||
}
|
||||
// Utils
|
||||
import { escapeRegex } from '../../utility';
|
||||
|
||||
const Home = (props: ComponentProps): JSX.Element => {
|
||||
export const Home = (): JSX.Element => {
|
||||
const {
|
||||
apps: { apps, loading: appsLoading },
|
||||
bookmarks: { categories, loading: bookmarksLoading },
|
||||
config: { config },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { getApps, getCategories } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
// 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 (props.apps.length === 0) {
|
||||
props.getApps();
|
||||
if (!apps.length) {
|
||||
getApps();
|
||||
}
|
||||
}, [props.getApps]);
|
||||
}, []);
|
||||
|
||||
// Load bookmark categories
|
||||
useEffect(() => {
|
||||
if (!categories.length) {
|
||||
getCategories();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.categories.length === 0) {
|
||||
props.getCategories();
|
||||
if (localSearch) {
|
||||
// Search through apps
|
||||
setAppSearchResult([
|
||||
...apps.filter(({ name, description }) =>
|
||||
new RegExp(escapeRegex(localSearch), 'i').test(
|
||||
`${name} ${description}`
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
// Search through bookmarks
|
||||
const category = { ...categories[0] };
|
||||
|
||||
category.name = 'Search Results';
|
||||
category.bookmarks = categories
|
||||
.map(({ bookmarks }) => bookmarks)
|
||||
.flat()
|
||||
.filter(({ name }) =>
|
||||
new RegExp(escapeRegex(localSearch), 'i').test(name)
|
||||
);
|
||||
|
||||
setBookmarkSearchResult([category]);
|
||||
} else {
|
||||
setAppSearchResult(null);
|
||||
setBookmarkSearchResult(null);
|
||||
}
|
||||
}, [props.getCategories]);
|
||||
|
||||
const dateAndTime = (): string => {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
|
||||
}
|
||||
|
||||
const greeter = (): string => {
|
||||
const now = new Date().getHours();
|
||||
let msg: string;
|
||||
|
||||
if (now >= 18) msg = 'Good evening!';
|
||||
else if (now >= 12) msg = 'Good afternoon!';
|
||||
else if (now >= 6) msg = 'Good morning!';
|
||||
else if (now >= 0) msg = 'Good night!';
|
||||
else msg = 'Hello!';
|
||||
|
||||
return msg;
|
||||
}
|
||||
}, [localSearch]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<header className={classes.Header}>
|
||||
<p>{dateAndTime()}</p>
|
||||
<Link to='/settings' className={classes.SettingsLink}>Go to Settings</Link>
|
||||
<span className={classes.HeaderMain}>
|
||||
<h1>{greeter()}</h1>
|
||||
<WeatherWidget />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<SectionHeadline title='Applications' link='/applications' />
|
||||
{props.appsLoading
|
||||
? <Spinner />
|
||||
: <AppGrid apps={props.apps.filter((app: App) => app.isPinned)} />
|
||||
}
|
||||
{!config.hideSearch ? (
|
||||
<SearchBar
|
||||
setLocalSearch={setLocalSearch}
|
||||
appSearchResult={appSearchResult}
|
||||
bookmarkSearchResult={bookmarkSearchResult}
|
||||
/>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
<div className={classes.HomeSpace}></div>
|
||||
<Header />
|
||||
|
||||
<SectionHeadline title='Bookmarks' link='/bookmarks' />
|
||||
{props.categoriesLoading
|
||||
? <Spinner />
|
||||
: <BookmarkGrid categories={props.categories.filter((category: Category) => category.isPinned)} />
|
||||
}
|
||||
{!isAuthenticated &&
|
||||
!apps.some((a) => a.isPinned) &&
|
||||
!categories.some((c) => c.isPinned) ? (
|
||||
<Message>
|
||||
Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>,
|
||||
login and start customizing your new homepage
|
||||
</Message>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Link to='/settings' className={classes.SettingsButton}>
|
||||
<Icon icon='mdiCog' color='var(--color-background)' />
|
||||
{!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? (
|
||||
<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>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{!config.hideCategories &&
|
||||
(isAuthenticated || categories.some((c) => c.isPinned)) ? (
|
||||
<Fragment>
|
||||
<SectionHeadline title="Bookmarks" link="/bookmarks" />
|
||||
{bookmarksLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<BookmarkGrid
|
||||
categories={
|
||||
!bookmarkSearchResult
|
||||
? categories.filter(
|
||||
({ isPinned, bookmarks }) => isPinned && bookmarks.length
|
||||
)
|
||||
: bookmarkSearchResult
|
||||
}
|
||||
totalCategories={categories.length}
|
||||
searching={!!localSearch}
|
||||
fromHomepage={true}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<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
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { getApps, getCategories })(Home);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,38 +1,30 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { GlobalState, Notification as _Notification } from '../../interfaces';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Notification as NotificationInterface } from '../../interfaces';
|
||||
|
||||
import classes from './NotificationCenter.module.css';
|
||||
|
||||
import Notification from '../UI/Notification/Notification';
|
||||
import { Notification } from '../UI';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
interface ComponentProps {
|
||||
notifications: _Notification[];
|
||||
}
|
||||
export const NotificationCenter = (): JSX.Element => {
|
||||
const { notifications } = useSelector((state: State) => state.notification);
|
||||
|
||||
const NotificationCenter = (props: ComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={classes.NotificationCenter}
|
||||
style={{ height: `${props.notifications.length * 75}px` }}
|
||||
style={{ height: `${notifications.length * 75}px` }}
|
||||
>
|
||||
{props.notifications.map((notification: _Notification) => {
|
||||
{notifications.map((notification: NotificationInterface) => {
|
||||
return (
|
||||
<Notification
|
||||
title={notification.title}
|
||||
message={notification.message}
|
||||
url={notification.url || null}
|
||||
id={notification.id}
|
||||
key={notification.id}
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
notifications: state.notification.notifications
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(NotificationCenter);
|
||||
);
|
||||
};
|
||||
|
|
13
client/src/components/Routing/ProtectedRoute.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useSelector } from 'react-redux';
|
||||
import { Redirect, Route, RouteProps } from 'react-router';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
export const ProtectedRoute = ({ ...rest }: RouteProps) => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Route {...rest} />;
|
||||
} else {
|
||||
return <Redirect to="/settings/app" />;
|
||||
}
|
||||
};
|
18
client/src/components/SearchBar/SearchBar.module.css
Normal file
|
@ -0,0 +1,18 @@
|
|||
.SearchBar {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
color: var(--color-primary);
|
||||
/* font-size: 20px; */
|
||||
margin-bottom: 20px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.SearchBar:focus {
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
}
|
132
client/src/components/SearchBar/SearchBar.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
import { useRef, useEffect, KeyboardEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { App, Category } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './SearchBar.module.css';
|
||||
|
||||
// Utils
|
||||
import { searchParser, urlParser, redirectUrl } from '../../utility';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
interface Props {
|
||||
setLocalSearch: (query: string) => void;
|
||||
appSearchResult: App[] | null;
|
||||
bookmarkSearchResult: Category[] | null;
|
||||
}
|
||||
|
||||
export const SearchBar = (props: Props): JSX.Element => {
|
||||
const { config, loading } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
|
||||
|
||||
// Search bar autofocus
|
||||
useEffect(() => {
|
||||
if (!loading && !config.disableAutofocus) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Listen for keyboard events outside of search bar
|
||||
useEffect(() => {
|
||||
const keyOutsideFocus = (e: any) => {
|
||||
const { key } = e as KeyboardEvent;
|
||||
|
||||
if (key === 'Escape') {
|
||||
clearSearch();
|
||||
} else if (document.activeElement !== inputRef.current) {
|
||||
if (key === '`') {
|
||||
inputRef.current.focus();
|
||||
clearSearch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keyup', keyOutsideFocus);
|
||||
|
||||
return () => window.removeEventListener('keyup', keyOutsideFocus);
|
||||
}, []);
|
||||
|
||||
const clearSearch = () => {
|
||||
inputRef.current.value = '';
|
||||
setLocalSearch('');
|
||||
};
|
||||
|
||||
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const {
|
||||
isLocal,
|
||||
encodedURL,
|
||||
primarySearch,
|
||||
secondarySearch,
|
||||
isURL,
|
||||
sameTab,
|
||||
rawQuery,
|
||||
} = searchParser(inputRef.current.value);
|
||||
|
||||
if (isLocal) {
|
||||
setLocalSearch(encodedURL);
|
||||
}
|
||||
|
||||
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
|
||||
if (!primarySearch.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 if query is not empty
|
||||
if (!/^ *$/.test(rawQuery)) {
|
||||
let template = primarySearch.template;
|
||||
|
||||
if (primarySearch.prefix === 'l') {
|
||||
template = secondarySearch.template;
|
||||
}
|
||||
|
||||
const url = `${template}${encodedURL}`;
|
||||
redirectUrl(url, sameTab);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Valid query -> redirect to search results
|
||||
const url = `${primarySearch.template}${encodedURL}`;
|
||||
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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
.text {
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.text a,
|
||||
.text span {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 30px 0;
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
57
client/src/components/Settings/AppDetails/AppDetails.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
// UI
|
||||
import { Button, SettingsHeadline } from '../../UI';
|
||||
import { AuthForm } from './AuthForm/AuthForm';
|
||||
import classes from './AppDetails.module.css';
|
||||
|
||||
// Store
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// Other
|
||||
import { checkVersion } from '../../../utility';
|
||||
|
||||
export const AppDetails = (): JSX.Element => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<SettingsHeadline text="Authentication" />
|
||||
<AuthForm />
|
||||
|
||||
{isAuthenticated && (
|
||||
<Fragment>
|
||||
<hr className={classes.separator} />
|
||||
|
||||
<div>
|
||||
<SettingsHeadline text="App version" />
|
||||
<p className={classes.text}>
|
||||
<a
|
||||
href="https://github.com/pawelmalak/flame"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Flame
|
||||
</a>{' '}
|
||||
version {process.env.REACT_APP_VERSION}
|
||||
</p>
|
||||
|
||||
<p className={classes.text}>
|
||||
See changelog{' '}
|
||||
<a
|
||||
href="https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<Button click={() => checkVersion(true)}>Check for updates</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
110
client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { FormEvent, Fragment, useEffect, useState, useRef } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { decodeToken, parseTokenExpire } from '../../../../utility';
|
||||
|
||||
// Other
|
||||
import { InputGroup, Button } from '../../../UI';
|
||||
import classes from '../AppDetails.module.css';
|
||||
|
||||
export const AuthForm = (): JSX.Element => {
|
||||
const { isAuthenticated, token } = useSelector((state: State) => state.auth);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { login, logout } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [tokenExpires, setTokenExpires] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
password: '',
|
||||
duration: '14d',
|
||||
});
|
||||
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
const decoded = decodeToken(token);
|
||||
const expiresIn = parseTokenExpire(decoded.exp);
|
||||
setTokenExpires(expiresIn);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const formHandler = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
login(formData);
|
||||
setFormData({
|
||||
password: '',
|
||||
duration: '14d',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{!isAuthenticated ? (
|
||||
<form onSubmit={formHandler}>
|
||||
<InputGroup>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="••••••"
|
||||
autoComplete="current-password"
|
||||
ref={passwordInputRef}
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
See
|
||||
<a
|
||||
href="https://github.com/pawelmalak/flame/wiki/Authentication"
|
||||
target="blank"
|
||||
>
|
||||
{` project wiki `}
|
||||
</a>
|
||||
to read more about authentication
|
||||
</span>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="duration">Session duration</label>
|
||||
<select
|
||||
id="duration"
|
||||
name="duration"
|
||||
value={formData.duration}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, duration: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="1d">1 day</option>
|
||||
<option value="14d">2 weeks</option>
|
||||
<option value="30d">1 month</option>
|
||||
<option value="1y">1 year</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Login</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<p className={classes.text}>
|
||||
You are logged in. Your session will expire{' '}
|
||||
<span>{tokenExpires}</span>
|
||||
</p>
|
||||
<Button click={logout}>Logout</Button>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
122
client/src/components/Settings/DockerSettings/DockerSettings.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { DockerSettingsForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { InputGroup, Button, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, dockerSettingsTemplate } from '../../../utility';
|
||||
|
||||
export const DockerSettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<DockerSettingsForm>(
|
||||
dockerSettingsTemplate
|
||||
);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
...config,
|
||||
});
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings
|
||||
await updateConfig(formData);
|
||||
};
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<DockerSettingsForm>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
<SettingsHeadline text="Docker" />
|
||||
{/* CUSTOM DOCKER SOCKET HOST */}
|
||||
<InputGroup>
|
||||
<label htmlFor="dockerHost">Docker host</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dockerHost"
|
||||
name="dockerHost"
|
||||
placeholder="dockerHost:port"
|
||||
value={formData.dockerHost}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* USE DOCKER API */}
|
||||
<InputGroup>
|
||||
<label htmlFor="dockerApps">Use Docker API</label>
|
||||
<select
|
||||
id="dockerApps"
|
||||
name="dockerApps"
|
||||
value={formData.dockerApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* UNPIN DOCKER APPS */}
|
||||
<InputGroup>
|
||||
<label htmlFor="unpinStoppedApps">
|
||||
Unpin stopped containers / other apps
|
||||
</label>
|
||||
<select
|
||||
id="unpinStoppedApps"
|
||||
name="unpinStoppedApps"
|
||||
value={formData.unpinStoppedApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* KUBERNETES SETTINGS */}
|
||||
<SettingsHeadline text="Kubernetes" />
|
||||
{/* USE KUBERNETES */}
|
||||
<InputGroup>
|
||||
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
|
||||
<select
|
||||
id="kubernetesApps"
|
||||
name="kubernetesApps"
|
||||
value={formData.kubernetesApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,100 @@
|
|||
import { Fragment, useState } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Query } from '../../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { Modal, Icon, Button, CompactTable, ActionIcons } from '../../../UI';
|
||||
|
||||
// Components
|
||||
import { QueriesForm } from './QueriesForm';
|
||||
|
||||
export const CustomQueries = (): JSX.Element => {
|
||||
const { customQueries, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { deleteQuery, createNotification } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
|
||||
|
||||
const updateHandler = (query: Query) => {
|
||||
setEditableQuery(query);
|
||||
setModalIsOpen(true);
|
||||
};
|
||||
|
||||
const deleteHandler = (query: Query) => {
|
||||
const currentProvider = config.defaultSearchProvider;
|
||||
const isCurrent = currentProvider === query.prefix;
|
||||
|
||||
if (isCurrent) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Cannot delete active provider',
|
||||
});
|
||||
} else if (
|
||||
window.confirm(`Are you sure you want to delete this provider?`)
|
||||
) {
|
||||
deleteQuery(query.prefix);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Modal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={() => setModalIsOpen(!modalIsOpen)}
|
||||
>
|
||||
{editableQuery ? (
|
||||
<QueriesForm
|
||||
modalHandler={() => setModalIsOpen(!modalIsOpen)}
|
||||
query={editableQuery}
|
||||
/>
|
||||
) : (
|
||||
<QueriesForm modalHandler={() => setModalIsOpen(!modalIsOpen)} />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<section>
|
||||
{customQueries.length ? (
|
||||
<CompactTable headers={['Name', 'Prefix', 'Actions']}>
|
||||
{customQueries.map((q: Query, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<span>{q.name}</span>
|
||||
<span>{q.prefix}</span>
|
||||
<ActionIcons>
|
||||
<span onClick={() => updateHandler(q)}>
|
||||
<Icon icon="mdiPencil" />
|
||||
</span>
|
||||
<span onClick={() => deleteHandler(q)}>
|
||||
<Icon icon="mdiDelete" />
|
||||
</span>
|
||||
</ActionIcons>
|
||||
</Fragment>
|
||||
))}
|
||||
</CompactTable>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Button
|
||||
click={() => {
|
||||
setEditableQuery(null);
|
||||
setModalIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Add new search provider
|
||||
</Button>
|
||||
</section>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,116 @@
|
|||
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
import { Query } from '../../../../interfaces';
|
||||
|
||||
import { Button, InputGroup, ModalForm } from '../../../UI';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
query?: Query;
|
||||
}
|
||||
|
||||
export const QueriesForm = (props: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { addQuery, updateQuery } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const { modalHandler, query } = props;
|
||||
|
||||
const [formData, setFormData] = useState<Query>({
|
||||
name: '',
|
||||
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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,242 @@
|
|||
// React
|
||||
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { Query, GeneralForm } from '../../../interfaces';
|
||||
|
||||
// Components
|
||||
import { CustomQueries } from './CustomQueries/CustomQueries';
|
||||
|
||||
// UI
|
||||
import { Button, SettingsHeadline, InputGroup } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, generalSettingsTemplate } from '../../../utility';
|
||||
|
||||
// Data
|
||||
import searchQueries from '../../../utility/searchQueries.json';
|
||||
|
||||
// Redux
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
export const GeneralSettings = (): JSX.Element => {
|
||||
const {
|
||||
config: { loading, customQueries, config },
|
||||
bookmarks: { categories },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig, sortApps, sortCategories, sortBookmarks } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const queries = searchQueries.queries;
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<GeneralForm>(
|
||||
generalSettingsTemplate
|
||||
);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
...config,
|
||||
});
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings
|
||||
await updateConfig(formData);
|
||||
|
||||
// Sort entities with new settings
|
||||
if (formData.useOrdering !== config.useOrdering) {
|
||||
sortApps();
|
||||
sortCategories();
|
||||
|
||||
for (let { id } of categories) {
|
||||
sortBookmarks(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<GeneralForm>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<form
|
||||
onSubmit={(e) => formSubmitHandler(e)}
|
||||
style={{ marginBottom: '30px' }}
|
||||
>
|
||||
{/* === GENERAL OPTIONS === */}
|
||||
<SettingsHeadline text="General" />
|
||||
{/* SORT TYPE */}
|
||||
<InputGroup>
|
||||
<label htmlFor="useOrdering">Sorting type</label>
|
||||
<select
|
||||
id="useOrdering"
|
||||
name="useOrdering"
|
||||
value={formData.useOrdering}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
>
|
||||
<option value="createdAt">By creation date</option>
|
||||
<option value="name">Alphabetical order</option>
|
||||
<option value="orderId">Custom order</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* === APPS OPTIONS === */}
|
||||
<SettingsHeadline text="Apps" />
|
||||
{/* PIN APPS */}
|
||||
<InputGroup>
|
||||
<label htmlFor="pinAppsByDefault">
|
||||
Pin new applications by default
|
||||
</label>
|
||||
<select
|
||||
id="pinAppsByDefault"
|
||||
name="pinAppsByDefault"
|
||||
value={formData.pinAppsByDefault ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* APPS OPPENING */}
|
||||
<InputGroup>
|
||||
<label htmlFor="appsSameTab">Open applications in the same tab</label>
|
||||
<select
|
||||
id="appsSameTab"
|
||||
name="appsSameTab"
|
||||
value={formData.appsSameTab ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* === BOOKMARKS OPTIONS === */}
|
||||
<SettingsHeadline text="Bookmarks" />
|
||||
{/* PIN CATEGORIES */}
|
||||
<InputGroup>
|
||||
<label htmlFor="pinCategoriesByDefault">
|
||||
Pin new categories by default
|
||||
</label>
|
||||
<select
|
||||
id="pinCategoriesByDefault"
|
||||
name="pinCategoriesByDefault"
|
||||
value={formData.pinCategoriesByDefault ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* BOOKMARKS OPPENING */}
|
||||
<InputGroup>
|
||||
<label htmlFor="bookmarksSameTab">
|
||||
Open bookmarks in the same tab
|
||||
</label>
|
||||
<select
|
||||
id="bookmarksSameTab"
|
||||
name="bookmarksSameTab"
|
||||
value={formData.bookmarksSameTab ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* === SEARCH OPTIONS === */}
|
||||
<SettingsHeadline text="Search" />
|
||||
<InputGroup>
|
||||
<label htmlFor="defaultSearchProvider">Primary search provider</label>
|
||||
<select
|
||||
id="defaultSearchProvider"
|
||||
name="defaultSearchProvider"
|
||||
value={formData.defaultSearchProvider}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
>
|
||||
{[...queries, ...customQueries].map((query: Query, idx) => {
|
||||
const isCustom = idx >= queries.length;
|
||||
|
||||
return (
|
||||
<option key={idx} value={query.prefix}>
|
||||
{isCustom && '+'} {query.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{formData.defaultSearchProvider === 'l' && (
|
||||
<InputGroup>
|
||||
<label htmlFor="secondarySearchProvider">
|
||||
Secondary search provider
|
||||
</label>
|
||||
<select
|
||||
id="secondarySearchProvider"
|
||||
name="secondarySearchProvider"
|
||||
value={formData.secondarySearchProvider}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
>
|
||||
{[...queries, ...customQueries].map((query: Query, idx) => {
|
||||
const isCustom = idx >= queries.length;
|
||||
|
||||
return (
|
||||
<option key={idx} value={query.prefix}>
|
||||
{isCustom && '+'} {query.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<span>
|
||||
Will be used when "Local search" is primary search provider and
|
||||
there are not any local results
|
||||
</span>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="searchSameTab">
|
||||
Open search results in the same tab
|
||||
</label>
|
||||
<select
|
||||
id="searchSameTab"
|
||||
name="searchSameTab"
|
||||
value={formData.searchSameTab ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
|
||||
{/* CUSTOM QUERIES */}
|
||||
<SettingsHeadline text="Custom search providers" />
|
||||
<CustomQueries />
|
||||
</Fragment>
|
||||
);
|
||||
};
|