Compare commits

...

129 commits

Author SHA1 Message Date
pawelmalak
3c347c854c
Merge pull request #432 from pawelmalak/v2.3.1
Version 2.3.1
2023-07-23 14:51:23 +02:00
Paweł Malak
89fa2980e6 Pushed version 2.3.1 2023-07-23 14:47:08 +02:00
Paweł Malak
7479ffb134 Updated MDI link. Opening link in the same tab will now allow to go back to the previous page 2023-07-23 10:59:37 +02:00
Paweł Malak
97884a5293 Fixed bug where color inputs in theme creator/editor were too small (fixing #429) 2023-07-23 10:45:26 +02:00
Paweł Malak
002a87a6df Changed input labels in settings (fixing #430) 2023-07-23 10:24:13 +02:00
Paweł Malak
17f0b7a553 Fixed bug where styles for SettingsHeadline weren\'t applied with current version of react-scripts 2023-07-23 00:40:57 +02:00
Paweł Malak
69ddc44796 Merge branch 'v2.3.1' of https://github.com/pawelmalak/flame into v2.3.1 2023-07-20 19:33:16 +02:00
pawelmalak
9e6d6fce73
Merge pull request #395 from davidchalifoux/codespace-2cc7
Enforce no border-radius on search bar
2023-07-20 19:33:01 +02:00
Paweł Malak
018ec0dd94 Fixed bug#270 where setting was not respected when local was set as primary search 2023-07-20 19:28:00 +02:00
pawelmalak
c2d580ee0d
Merge pull request #284 from pmjklemm/fix_sameTab
bugfix: sameTab does not work if prefix is localSearch
2023-07-20 19:24:49 +02:00
Paweł Malak
188c5bc04b Fixed react errors while importing named exports from JSON files 2023-07-20 19:04:31 +02:00
Paweł Malak
35ae5f9ee7 Bumped react-scripts to version 5 2023-07-20 18:58:19 +02:00
David Chalifoux
ebd98d29c1 Enforce no border-radius on search bar 2022-10-16 19:14:12 +00:00
pawelmalak
446b4095f6
Merge pull request #334 from pawelmalak/feature
Version 2.3.0
2022-03-25 15:16:19 +01:00
Paweł Malak
2b5b3494f2 Pushed version 2.3.0 2022-03-25 14:56:48 +01:00
Paweł Malak
6fb5737118 Small bug fixes and UI improvements 2022-03-25 14:51:56 +01:00
Paweł Malak
16121ff547 Added option to set secondary search provider 2022-03-25 14:28:40 +01:00
Paweł Malak
2c0491a5b0 Fixed visual bug with custom theme editor modal. Added Mint theme 2022-03-25 14:07:53 +01:00
Paweł Malak
0f6d79683e Fixed bug where pressing Enter with empty search bar would redirect to search results 2022-03-25 13:37:53 +01:00
Paweł Malak
0b3eb2e87f Fixed bug where user could create empty app or bookmark which was causing page to go blank 2022-03-25 13:16:57 +01:00
Paweł Malak
668edb03d3 Functionality to delete and edit custom themes 2022-03-25 12:13:19 +01:00
Paweł Malak
ad92de141b API routes to edit and delete custom themes. Added ThemeEditor table 2022-03-25 11:33:42 +01:00
Paweł Malak
bd96f6ca50 Added CompactTable and ActionIcons UI components 2022-03-25 11:04:16 +01:00
Paweł Malak
9ab6c65d85 Added custom theme creator 2022-03-24 16:07:14 +01:00
Paweł Malak
378dd8e36d Fixed color of weather icon when changing theme 2022-03-24 14:56:36 +01:00
Paweł Malak
b8af178cbf API routes to get and add themes 2022-03-24 14:48:10 +01:00
Paweł Malak
48e28b9abd Added user themes section to Theme settings 2022-03-23 16:13:34 +01:00
Paweł Malak
89bd921875 Changed how theme is set and stored on client 2022-03-23 14:49:35 +01:00
Paweł Malak
e427fbf54c Added theme string normalization to initial process. Added getThemes controller 2022-03-23 14:13:14 +01:00
Paweł Malak
ee0b435493 Separated theme components 2022-03-23 13:02:32 +01:00
pawelmalak
baac78021a
Merge pull request #312 from pawelmalak/feature
Version 2.2.2
2022-03-21 15:05:07 +01:00
Paweł Malak
c2e81832a9 Added build scripts. Pushed version 2.2.2 2022-03-21 12:16:48 +01:00
pawelmalak
58d021dde6
Merge pull request #314 from LuckyF/chown-on-startup
fix: Update Permissions on Startup
2022-03-21 11:46:57 +01:00
Lukas Frischknecht
1098a04fb9
fix: Update Permissions on Startup 2022-02-17 19:27:40 +01:00
Paweł Malak
76dc3c44c8 Added option to get user location directly from the app 2022-02-14 13:58:57 +01:00
Paweł Malak
2d5cce9fdb Fixed bug with app description not updating. Fixed bug with local search prefix not working 2022-02-14 13:22:41 +01:00
Paweł Malak
12295a6f68 Moved some settings between general and ui tabs - continued. Fixed settings links in apps and categories tables 2022-02-04 15:01:40 +01:00
Paweł Malak
500e138643 Moved some settings between general and ui tabs 2022-02-04 14:59:48 +01:00
Paweł Malak
04e80b339c Changed order and names of some setting tabs 2022-02-04 13:09:47 +01:00
pawelmalak
750891cffa
Merge pull request #288 from pawelmalak/feature
Version 2.2.1
2022-01-08 14:49:07 +01:00
Paweł Malak
fac8ef4027 Pushed version 2.2.1 2022-01-08 14:03:10 +01:00
Paweł Malak
19fb14d553 Merge branch 'feature' of https://github.com/pawelmalak/flame into feature 2022-01-08 13:17:26 +01:00
pawelmalak
5c84d90bf1
Merge pull request #278 from soulteary/bugfix/local-search-support-cjk
bugfix: local-search support CJK
2022-01-08 13:17:22 +01:00
Paweł Malak
6767b1dac0 Merge branch 'feature' of https://github.com/pawelmalak/flame into feature 2022-01-08 12:58:11 +01:00
pawelmalak
e0ecf34ced
Merge pull request #282 from soulteary/chore/background-task-optimization
chore: bg-task optimization
2022-01-08 12:58:06 +01:00
Paweł Malak
396c442062 Added app descriptions to local search parser 2022-01-08 12:48:33 +01:00
pmjklemm
eaab31aacc fix sameTab for prefix==l 2022-01-04 21:11:13 +01:00
soulteary
0044d265d1 chore: bg-task optimization 2022-01-04 14:18:54 +08:00
soulteary
19a910a91c bugfix: local-search support cjk 2022-01-04 13:26:50 +08:00
pawelmalak
6d8ce5361a
Merge pull request #262 from pawelmalak/feature
Version 2.2.0
2021-12-17 13:41:29 +01:00
Paweł Malak
d2f99a5ec0 Pushed version 2.2.0 2021-12-17 12:56:51 +01:00
pawelmalak
c985fc17bf
Merge pull request #254 from grahamhelton/master
Changed docker-run syntax to be more user friendly
2021-12-17 12:30:25 +01:00
pawelmalak
73cf66c592
Merge pull request #261 from pawelmalak/bug-k3s
Bug k3s
2021-12-17 12:29:49 +01:00
Paweł Malak
ee044ed2ff Fixed fatal error while deploying flame to cluster 2021-12-17 12:28:37 +01:00
pawelmalak
9dd3bd1f53
Merge pull request #248 from IDevJoe/master
Remove fatal error from docker secrets
2021-12-17 11:30:19 +01:00
Graham Helton
55a064c2a4
Changed docker-run syntax to be more user friendly 2021-12-11 23:39:23 -05:00
Joe Longendyke
c8436aaf03
Use tagged idevjoe/docker-secret 2021-12-08 08:19:42 +09:00
Joe Longendyke
edc01a341c
Modify package.json for fixed docker-secret 2021-12-08 06:12:37 +09:00
Paweł Malak
531ede0adf Added option to set custom description for apps 2021-12-07 16:48:24 +01:00
Joe Longendyke
a536ad49ea
Remove fatal error from docker secrets
This commit fixes #242 by catching the error thrown by getSecrets(). The underlying issue exists in docker-secret and has to do with the serviceAccount secret installed automatically by kubernetes.
2021-12-06 12:37:28 +09:00
pawelmalak
b08181e712
Merge pull request #241 from pawelmalak/feature
Version 2.1.1
2021-12-02 17:32:40 +01:00
Paweł Malak
bc077b658d Pushed version 2.1.1 2021-12-02 17:05:20 +01:00
Paweł Malak
48b91581b8 Docker secrets integration 2021-12-02 16:43:13 +01:00
pawelmalak
d1d32cdbe6
Merge pull request #211 from abbiewade/feature-docker-secret-integration
Add integration for docker secrets
2021-12-02 14:23:57 +01:00
pawelmalak
2b25a67bbf
Merge branch 'feature' into feature-docker-secret-integration 2021-12-02 14:23:31 +01:00
Paweł Malak
64f1f28982 Added docker-secret for PR review. Updated some dependencies 2021-12-02 14:21:43 +01:00
Paweł Malak
f49ab6fd0d Updated node to version 16 2021-12-02 14:18:09 +01:00
Paweł Malak
068c8ab2e7 Changed some messages and buttons to make it easier to open bookmarks editor 2021-12-02 14:12:23 +01:00
pawelmalak
2ca90a18e1
Merge pull request #226 from pawelmalak/feature
Version 2.1.0
2021-11-26 14:52:32 +01:00
Paweł Malak
fcf2b87d1c Pushed version 2.1.0 2021-11-26 14:40:52 +01:00
Paweł Malak
d5610ad6be Fixed bug with alphabetical order not working for bookmarks. Minor changes related to bookmarks form 2021-11-26 14:04:46 +01:00
Paweł Malak
ec5f50aba4 Added option to reorder bookmarks 2021-11-25 16:54:27 +01:00
Paweł Malak
a02814aa02 Split BookmarksTable into separate components. Minor changes to reducers 2021-11-25 16:44:24 +01:00
Paweł Malak
e15c2a2f07 Reorder bookmarks action. Small refactor of state reducers 2021-11-22 17:15:01 +01:00
Paweł Malak
f1f7b698f8 Db migration to support custom order of bookmarks. Created route to reorder bookmarks. Added more sorting options to bookmark controllers. Simplified ordering in getAllApps controller 2021-11-22 16:45:59 +01:00
Paweł Malak
dfdd49cf4a Moved entityInUpdate to app state. It applies for apps, categories and bookmarks 2021-11-22 14:36:00 +01:00
Paweł Malak
d110d9b732 Empty sections will be hidden from guests. Fixed temperature value rounding. Added welcome message 2021-11-22 12:29:47 +01:00
Paweł Malak
882f011d07 Cleaned up Apps and Bookmarks containers. Changed AppTable to use TableActions component 2021-11-20 14:51:47 +01:00
Paweł Malak
8941f8f2f4 Added support for .ico files 2021-11-20 14:18:42 +01:00
Paweł Malak
089ace562a Moved table actions to separate component 2021-11-20 14:17:51 +01:00
pawelmalak
f963c1980b
Merge pull request #216 from pawelmalak/fix
Fixed failing migration
2021-11-20 11:36:39 +01:00
Paweł Malak
1ff2c7afd9 Fixed failing migration 2021-11-20 11:19:53 +01:00
Abbie Wade
7a8808df4f added integration for docker secrets 2021-11-20 10:54:34 +11:00
pawelmalak
4c1c0087c7 Update issue templates 2021-11-19 22:07:03 +01:00
pawelmalak
fd7d8e65c8
Merge pull request #206 from pawelmalak/feature
Version 2.0.1
2021-11-19 15:02:58 +01:00
Paweł Malak
55b70eebbd Updated changelog 2021-11-19 15:02:33 +01:00
Paweł Malak
d13b890e16 Fixed bug with value parsing if custom icon was used 2021-11-19 14:06:38 +01:00
Paweł Malak
c2e9f82cd6 Improved default theme setting. Pushed version 2.0.1 2021-11-19 13:24:07 +01:00
pawelmalak
e0f6034868
Merge pull request #205 from pawelmalak/auth-header
Changed auth header
2021-11-19 12:42:05 +01:00
Paweł Malak
e13f6f2612 Changed auth header 2021-11-19 12:41:32 +01:00
Paweł Malak
85a65aef52 Added option to hide header greetings and date separately 2021-11-18 16:03:44 +01:00
Paweł Malak
5cf7708ab8 Added humidity option to weather widget 2021-11-18 15:14:53 +01:00
Paweł Malak
a549149452 Database migration to support more weather properties 2021-11-18 14:16:57 +01:00
Paweł Malak
e2285e2deb Moved Themer to Settings. Added option to set default theme 2021-11-18 13:47:27 +01:00
Paweł Malak
426766225b Fixed bug with custom icons not working with apps 2021-11-18 13:05:32 +01:00
pawelmalak
1220c56fc5
Fixed bug with adding new apps with custom icon (#180)
* Bump url-parse from 1.5.1 to 1.5.3 in /client

Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.1 to 1.5.3.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.1...1.5.3)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump dns-packet from 1.3.1 to 1.3.4 in /client

Bumps [dns-packet](https://github.com/mafintosh/dns-packet) from 1.3.1 to 1.3.4.
- [Release notes](https://github.com/mafintosh/dns-packet/releases)
- [Changelog](https://github.com/mafintosh/dns-packet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mafintosh/dns-packet/compare/v1.3.1...v1.3.4)

---
updated-dependencies:
- dependency-name: dns-packet
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump ws from 6.2.1 to 6.2.2 in /client

Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/6.2.1...6.2.2)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed #177

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-15 16:34:12 +01:00
pawelmalak
7eb8ec228a
Merge pull request #169 from pawelmalak/v2
Version 2.0.0
2021-11-15 13:46:05 +01:00
Paweł Malak
cb2326bb04 Pushed version 2.0.0 2021-11-15 11:40:33 +01:00
Paweł Malak
51a0da8f10 Removed additional weather logging 2021-11-15 00:58:47 +01:00
Paweł Malak
07cd725d4a Added new search bar shortcut. Fixed bug with forms still being visible after logout. Fixed bug with config fetching order 2021-11-14 23:20:37 +01:00
Paweł Malak
d86ebe3e58 Server db utils changes 2021-11-13 23:28:43 +01:00
Paweł Malak
91e99e1bcc Update README and remove old screenshots. Added password to Dockerfiles 2021-11-13 23:25:50 +01:00
Paweł Malak
b6b0857f17 Update README and CHANGELOG 2021-11-13 22:47:59 +01:00
pawelmalak
9f01d9cb12
Merge pull request #166 from pawelmalak/dockerfiles
Dockerfile changes
2021-11-13 12:27:45 +01:00
Paweł Malak
b4eb35c591 Dockerfile changes 2021-11-13 12:03:35 +01:00
pawelmalak
7e66f6b49f
Merge pull request #160 from petemidge/master
Update searchQueries.json
2021-11-13 11:43:00 +01:00
Paweł Malak
b848cfd921 Added function to escape regex characters 2021-11-12 14:02:19 +01:00
Paweł Malak
6281994be8 Trim icon name. Filter private bookmarks if user is not authenticated 2021-11-12 13:09:33 +01:00
Paweł Malak
d94a6cea5a Added auth headers to api requests 2021-11-12 12:38:01 +01:00
Paweł Malak
0d36c5cf94 Backend: auth for bookmarks and categories 2021-11-11 16:43:00 +01:00
Paweł Malak
22471d64c7 Backend: auth for config and queries. Refactor of middleware exports 2021-11-11 16:18:31 +01:00
Paweł Malak
e3f167921c Added auth middleware. Added access control to apps 2021-11-11 16:01:56 +01:00
Paweł Malak
d1c61bb393 Moved auth form. Added auto login and logout functionality 2021-11-11 14:45:58 +01:00
Paweł Malak
1571981252 Created separate settings for Docker 2021-11-10 16:45:30 +01:00
Paweł Malak
5805c708d2 Added login route and token signing 2021-11-10 16:14:22 +01:00
Paweł Malak
ea57dbf750 Added current time to header 2021-11-10 14:19:41 +01:00
pawelmalak
76e68db06f
Dependencies (#163)
* Bump tar from 6.1.0 to 6.1.11 in /client

Bumps [tar](https://github.com/npm/node-tar) from 6.1.0 to 6.1.11.
- [Release notes](https://github.com/npm/node-tar/releases)
- [Changelog](https://github.com/npm/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-tar/compare/v6.1.0...v6.1.11)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump path-parse from 1.0.6 to 1.0.7 in /client

Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump tmpl from 1.0.4 to 1.0.5 in /client

Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-11-10 13:56:06 +01:00
Paweł Malak
e4690d5d9c Added auth form. Added login and logout actions 2021-11-10 13:53:28 +01:00
Paweł Malak
f5ed85427e Added support for setting icons via URL 2021-11-10 11:42:00 +01:00
Paweł Malak
d83e3056c6 Split categories and bookmarks forms into separate files. Added visibility functionality to categories and bookmarks 2021-11-09 23:40:58 +01:00
Paweł Malak
f127a354ef Fixed state bug while updating categories. Fixed bug with bookmarks not being displayed under categories. 2021-11-09 15:51:50 +01:00
Paweł Malak
0d5a4c418e Fixed notification emitting 2021-11-09 15:04:13 +01:00
Paweł Malak
969bdb7d24 Components: refactored rest of the components to use new state. Minor changes to exports, imports and props 2021-11-09 14:33:51 +01:00
Paweł Malak
89d935e27f Components: refactored UI components to use new state. Minor changes to exports and props 2021-11-09 13:46:07 +01:00
Paweł Malak
adc017c48d App state: refactored reducers and actions for apps, categories and bookmarks 2021-11-09 13:19:53 +01:00
Paweł Malak
7e89ab0204 App state: refactored reducers and actions for config, theme and notifications 2021-11-09 12:21:36 +01:00
Pete Midgley
1f2fedf754
Update searchQueries.json
Added Wikipedia (English)
2021-11-09 21:26:11 +11:00
Paweł Malak
d1738a0a3e Set app visibility 2021-11-08 23:40:30 +01:00
Paweł Malak
ee9aefa4fa Modified models to support resource visibility. Added migration 2021-11-08 23:39:42 +01:00
233 changed files with 34975 additions and 14684 deletions

1
.dev/build_dev.sh Normal file
View file

@ -0,0 +1 @@
docker build -t flame:dev -f .docker/Dockerfile .

2
.dev/build_latest.sh Normal file
View 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
View 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 .

View file

@ -1,4 +1,4 @@
FROM node:14 as builder
FROM node:16 as builder
WORKDIR /app
@ -16,7 +16,7 @@ RUN mkdir -p ./public ./data \
&& mv ./client/build/* ./public \
&& rm -rf ./client
FROM node:14-alpine
FROM node:16-alpine
COPY --from=builder /app /app
@ -25,5 +25,6 @@ WORKDIR /app
EXPOSE 5005
ENV NODE_ENV=production
ENV PASSWORD=flame_password
CMD ["node", "server.js"]
CMD ["sh", "-c", "chown -R node /app/data && node server.js"]

View file

@ -1,16 +1,26 @@
FROM node:lts-alpine as build-front
RUN apk add --no-cache curl
WORKDIR /app
COPY ./client .
RUN npm install --production \
&& npm run build
FROM node:lts-alpine
WORKDIR /app
RUN mkdir -p ./public
COPY --from=build-front /app/build/ ./public
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "skaffold"]
CMD ["npm", "run", "skaffold"]

View file

@ -1,10 +1,11 @@
FROM node:14 as builder
FROM node:16-alpine3.11 as builder
WORKDIR /app
COPY package*.json ./
RUN npm install --production
RUN apk --no-cache --virtual build-dependencies add python python3 make g++ \
&& npm install --production
COPY . .
@ -16,7 +17,7 @@ RUN mkdir -p ./public ./data \
&& mv ./client/build/* ./public \
&& rm -rf ./client
FROM node:14-alpine
FROM node:16-alpine3.11
COPY --from=builder /app /app
@ -25,5 +26,6 @@ WORKDIR /app
EXPOSE 5005
ENV NODE_ENV=production
ENV PASSWORD=flame_password
CMD ["node", "server.js"]
CMD ["sh", "-c", "chown -R node /app/data && node server.js"]

View 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

View file

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

4
.env
View file

@ -1,3 +1,5 @@
PORT=5005
NODE_ENV=development
VERSION=1.7.4
VERSION=2.3.1
PASSWORD=flame_password
SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b

27
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/_bookmarks.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

BIN
.github/_home.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

BIN
.github/apps.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
.github/bookmarks.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
.github/home.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
.github/settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Before After
Before After

3
.gitignore vendored
View file

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

View file

@ -1 +1,2 @@
*.md
*.md
docker-compose.yml

View file

@ -1,3 +1,69 @@
### 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))
@ -62,12 +128,12 @@
- Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
- Added changelog file
### v1.6 (2021-07-17)
### v1.6.0 (2021-07-17)
- Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
- Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
- Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
### v1.5 (2021-06-24)
### v1.5.0 (2021-06-24)
- Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental)
- Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12))
- Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27))
@ -75,7 +141,7 @@
- Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
- Improved Logger
### v1.4 (2021-06-18)
### v1.4.0 (2021-06-18)
- Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
- Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
- Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
@ -84,14 +150,14 @@
- Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38))
- Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40))
### v1.3 (2021-06-14)
### v1.3.0 (2021-06-14)
- Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
- Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
- Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
- Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
- Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
### v1.2 (2021-06-10)
### v1.2.0 (2021-06-10)
- Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2))
- Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7))
- Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11))
@ -100,11 +166,11 @@
- Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18))
- Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20))
### v1.1 (2021-06-09)
### v1.1.0 (2021-06-09)
- Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3))
- Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3))
- Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4))
- Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5))
### v1.0 (2021-06-08)
### v1.0.0 (2021-06-08)
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.

164
README.md
View file

@ -1,37 +1,19 @@
# Flame
![Homescreen screenshot](./.github/_home.png)
![Homescreen screenshot](.github/home.png)
## Description
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary.
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary.
## Technology
- Backend
- Node.js + Express
- Sequelize ORM + SQLite
- Frontend
- React
- Redux
- TypeScript
- Deployment
- Docker
- Kubernetes
## Development
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
cd flame
# run only once
npm run dev-init
# start backend and frontend development servers
npm run dev
```
## Functionality
- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors
- 📌 Pin your favourite items to the homescreen for quick and easy access
- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
- 🔑 Authentication system to protect your settings, apps and bookmarks
- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS, 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
@ -40,48 +22,75 @@ npm run dev
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
```sh
docker pull pawelmalak/flame:latest
docker pull pawelmalak/flame
# for ARM architecture (e.g. RaspberryPi)
docker pull pawelmalak/flame:multiarch
```
#### Building images
```sh
# build image for amd64 only
docker build -t flame .
# build multiarch image for amd64, armv7 and arm64
# building failed multiple times with 2GB memory usage limit so you might want to increase it
docker buildx build \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-f Dockerfile.multiarch \
-t flame:multiarch .
# installing specific version
docker pull pawelmalak/flame:2.0.0
```
#### Deployment
```sh
# run container
docker run -p 5005:5005 -v /path/to/data:/app/data flame
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password 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: '2.1'
version: '3.6'
services:
flame:
image: pawelmalak/flame:latest
image: pawelmalak/flame
container_name: flame
volumes:
- <host_dir>:/app/data
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature
- /path/to/host/data:/app/data
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration
ports:
- 5005:5005
secrets:
- password # optional but required for (1)
environment:
- PASSWORD=flame_password
- PASSWORD_FILE=/run/secrets/password # optional but required for (1)
restart: unless-stopped
# optional but required for Docker secrets (1)
secrets:
password:
file: /path/to/secrets/password
```
##### Docker Secrets
All environment variables can be overwritten by appending `_FILE` to the variable value. For example, you can use `PASSWORD_FILE` to pass through a docker secret instead of `PASSWORD`. If both `PASSWORD` and `PASSWORD_FILE` are set, the docker secret will take precedent.
```bash
# ./secrets/flame_password
my_custom_secret_password_123
# ./docker-compose.yml
secrets:
password:
file: ./secrets/flame_password
```
#### Skaffold
@ -95,39 +104,56 @@ skaffold dev
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
## Functionality
## Development
- Applications
- Create, update, delete and organize applications using GUI
- Pin your favourite apps to the homescreen
### Technology
![Homescreen screenshot](./.github/_apps.png)
- Backend
- Node.js + Express
- Sequelize ORM + SQLite
- Frontend
- React
- Redux
- TypeScript
- Deployment
- Docker
- Kubernetes
- Bookmarks
- Create, update, delete and organize bookmarks and categories using GUI
- Pin your favourite categories to the homescreen
- Import html bookmarks (experimental)
### Creating dev environment
![Homescreen screenshot](./.github/_bookmarks.png)
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
cd flame
- Weather
# run only once
npm run dev-init
- Get current temperature, cloud coverage and weather status with animated icons
# start backend and frontend development servers
npm run dev
```
- Themes
- Customize your page by choosing from 15 color themes
## Screenshots
![Homescreen screenshot](./.github/_themes.png)
![Apps screenshot](.github/apps.png)
![Bookmarks screenshot](.github/bookmarks.png)
![Settings screenshot](.github/settings.png)
![Themes screenshot](.github/themes.png)
## Usage
### Authentication
Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
### Search bar
#### Searching
To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
> You can change where to open search results (same/new tab) in the settings
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).
@ -151,7 +177,7 @@ labels:
# - flame.icon=custom to make changes in app. ie: custom icon upload
```
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section
> "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.
@ -199,7 +225,7 @@ metadata:
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
```
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker
### Import HTML Bookmarks (Experimental)
@ -209,7 +235,7 @@ metadata:
- Backup your `db.sqlite` before running script!
- Known Issues:
- generated icons are sometimes incorrect
```bash
pip3 install Pillow, beautifulsoup4

4
api.js
View file

@ -1,6 +1,6 @@
const { join } = require('path');
const express = require('express');
const errorHandler = require('./middleware/errorHandler');
const { errorHandler } = require('./middleware');
const api = express();
@ -21,6 +21,8 @@ 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);

View file

@ -1 +1 @@
REACT_APP_VERSION=1.7.4
REACT_APP_VERSION=2.3.1

33607
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,6 @@
"@types/jest": "^27.0.2",
"@types/node": "^16.11.6",
"@types/react": "^17.0.34",
"@types/react-autosuggest": "^10.1.5",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.11",
"@types/react-redux": "^7.1.20",
@ -19,13 +18,13 @@
"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-autosuggest": "^10.1.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"react-scripts": "^5.0.1",
"redux": "^4.1.2",
"redux-devtools-extension": "^2.13.9",
"redux-thunk": "^2.4.0",

View file

@ -1,38 +1,82 @@
import { useEffect } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { fetchQueries, getConfig, setTheme } from './store/actions';
import 'external-svg-loader';
// Redux
import { store } from './store/store';
import { Provider } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { autoLogin, getConfig } from './store/action-creators';
import { actionCreators, store } from './store';
import { State } from './store/reducers';
// Utils
import { checkVersion } from './utility';
import { checkVersion, decodeToken, parsePABToTheme } from './utility';
// Routes
import Home from './components/Home/Home';
import Apps from './components/Apps/Apps';
import Settings from './components/Settings/Settings';
import Bookmarks from './components/Bookmarks/Bookmarks';
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
import { Home } from './components/Home/Home';
import { Apps } from './components/Apps/Apps';
import { Settings } from './components/Settings/Settings';
import { Bookmarks } from './components/Bookmarks/Bookmarks';
import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
// Load config
// Get config
store.dispatch<any>(getConfig());
// Set theme
if (localStorage.theme) {
store.dispatch<any>(setTheme(localStorage.theme));
// Validate token
if (localStorage.token) {
store.dispatch<any>(autoLogin());
}
// Check for updates
checkVersion();
export const App = (): JSX.Element => {
const { config, loading } = useSelector((state: State) => state.config);
// fetch queries
store.dispatch<any>(fetchQueries());
const dispath = useDispatch();
const { fetchQueries, setTheme, logout, createNotification, 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]);
const App = (): JSX.Element => {
return (
<Provider store={store}>
<>
<BrowserRouter>
<Switch>
<Route exact path="/" component={Home} />
@ -42,8 +86,6 @@ const App = (): JSX.Element => {
</Switch>
</BrowserRouter>
<NotificationCenter />
</Provider>
</>
);
};
export default App;

View file

@ -0,0 +1,12 @@
.TableActions {
display: flex;
align-items: center;
}
.TableAction {
width: 22px;
}
.TableAction:hover {
cursor: pointer;
}

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

View file

@ -1,35 +1,40 @@
import classes from './AppCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser, urlParser } from '../../../utility';
import { Icon } from '../../UI';
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
import { App, Config, GlobalState } from '../../../interfaces';
import { connect } from 'react-redux';
import { App } from '../../../interfaces';
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
interface ComponentProps {
interface Props {
app: App;
pinHandler?: Function;
config: Config;
}
const AppCard = (props: ComponentProps): JSX.Element => {
const [displayUrl, redirectUrl] = urlParser(props.app.url);
export const AppCard = ({ app }: Props): JSX.Element => {
const { config } = useSelector((state: State) => state.config);
const [displayUrl, redirectUrl] = urlParser(app.url);
let iconEl: JSX.Element;
const { icon } = props.app;
const { icon } = app;
if (isImage(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
if (/.(jpeg|jpg|png)$/i.test(icon)) {
iconEl = (
<img
src={`/uploads/${icon}`}
alt={`${props.app.name} icon`}
src={source}
alt={`${app.name} icon`}
className={classes.CustomIcon}
/>
);
} else if (/.(svg)$/i.test(icon)) {
} else if (isSvg(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = (
<div className={classes.CustomIcon}>
<svg
data-src={`/uploads/${icon}`}
data-src={source}
fill="var(--color-primary)"
className={classes.CustomIcon}
></svg>
@ -42,23 +47,15 @@ const AppCard = (props: ComponentProps): JSX.Element => {
return (
<a
href={redirectUrl}
target={props.config.appsSameTab ? '' : '_blank'}
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>{displayUrl}</span>
<h5>{app.name}</h5>
<span>{!app.description.length ? displayUrl : app.description}</span>
</div>
</a>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(AppCard);

View file

@ -1,50 +1,49 @@
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
import { connect } from 'react-redux';
import { addApp, updateApp } from '../../../store/actions';
import { App, NewApp } from '../../../interfaces';
import { useDispatch, useSelector } from 'react-redux';
import { NewApp } from '../../../interfaces';
import classes from './AppForm.module.css';
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
import { ModalForm, InputGroup, Button } from '../../UI';
import { inputHandler, newAppTemplate } from '../../../utility';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
interface ComponentProps {
interface Props {
modalHandler: () => void;
addApp: (formData: NewApp | FormData) => any;
updateApp: (id: number, formData: NewApp | FormData) => any;
app?: App;
}
const AppForm = (props: ComponentProps): JSX.Element => {
export const AppForm = ({ modalHandler }: Props): JSX.Element => {
const { appInUpdate } = useSelector((state: State) => state.apps);
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>({
name: '',
url: '',
icon: '',
});
const [formData, setFormData] = useState<NewApp>(newAppTemplate);
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,
});
};
@ -57,49 +56,58 @@ const AppForm = (props: ComponentProps): JSX.Element => {
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): 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('description', formData.description);
data.append('url', formData.url);
data.append('isPublic', `${formData.isPublic ? 1 : 0}`);
return data;
};
if (!props.app) {
if (!appInUpdate) {
if (customIcon) {
const data = createFormData();
props.addApp(data);
addApp(data);
} else {
props.addApp(formData);
addApp(formData);
}
} else {
if (customIcon) {
const data = createFormData();
props.updateApp(props.app.id, data);
updateApp(appInUpdate.id, data);
} else {
props.updateApp(props.app.id, formData);
props.modalHandler();
updateApp(appInUpdate.id, formData);
modalHandler();
}
}
setFormData({
name: '',
url: '',
icon: '',
});
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"
@ -110,6 +118,8 @@ const AppForm = (props: ComponentProps): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* URL */}
<InputGroup>
<label htmlFor="url">App URL</label>
<input
@ -121,21 +131,29 @@ const AppForm = (props: ComponentProps): JSX.Element => {
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* DESCRIPTION */}
<InputGroup>
<label htmlFor="description">App description</label>
<input
type="text"
name="description"
id="description"
placeholder="My self-hosted app"
value={formData.description}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target="_blank"
rel="noreferrer"
>
{' '}
Check supported URL formats
</a>
Optional - If description is not set, app URL will be displayed
</span>
</InputGroup>
{/* ICON */}
{!useCustomIcon ? (
// use mdi icon
<InputGroup>
<label htmlFor="icon">App Icon</label>
<label htmlFor="icon">App icon</label>
<input
type="text"
name="icon"
@ -146,8 +164,8 @@ const AppForm = (props: ComponentProps): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI.
<a href="https://materialdesignicons.com/" target="blank">
Use icon name from MDI or pass a valid URL.
<a href="https://pictogrammers.com/library/mdi/" target="blank">
{' '}
Click here for reference
</a>
@ -169,7 +187,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
id="icon"
required
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg"
accept=".jpg,.jpeg,.png,.svg,.ico"
/>
<span
onClick={() => {
@ -182,7 +200,22 @@ const AppForm = (props: ComponentProps): JSX.Element => {
</span>
</InputGroup>
)}
{!props.app ? (
{/* 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>
@ -190,5 +223,3 @@ const AppForm = (props: ComponentProps): JSX.Element => {
</ModalForm>
);
};
export default connect(null, { addApp, updateApp })(AppForm);

View file

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

View file

@ -2,52 +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>
);
} else {
if (props.totalApps) {
if (props.searching) {
apps = (
<p className={classes.AppsMessage}>
No apps match your search criteria
</p>
);
} else {
apps = (
<p className={classes.AppsMessage}>
There are no pinned applications. You can pin them from the{' '}
<Link to="/applications">/applications</Link> menu
</p>
);
}
if (props.searching || props.apps.length) {
if (!props.apps.length) {
apps = <Message>No apps match your search criteria</Message>;
} else {
apps = (
<p className={classes.AppsMessage}>
<div className={classes.AppGrid}>
{props.apps.map((app: App): JSX.Element => {
return <AppCard key={app.id} app={app} />;
})}
</div>
);
}
} else {
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
</p>
</Message>
);
}
}
return apps;
};
export default AppGrid;

View file

@ -1,4 +1,4 @@
import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
import { Fragment, useState, useEffect } from 'react';
import {
DragDropContext,
Droppable,
@ -8,78 +8,42 @@ import {
import { Link } from 'react-router-dom';
// Redux
import { connect } from 'react-redux';
import {
pinApp,
deleteApp,
reorderApps,
updateConfig,
createNotification,
} from '../../../store/actions';
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { App, Config, GlobalState, NewNotification } from '../../../interfaces';
import { App } from '../../../interfaces';
// CSS
import classes from './AppTable.module.css';
// Other
import { Message, Table } from '../../UI';
import { TableActions } from '../../Actions/TableActions';
// UI
import Icon from '../../UI/Icons/Icon/Icon';
import Table from '../../UI/Table/Table';
interface ComponentProps {
apps: App[];
config: Config;
pinApp: (app: App) => void;
deleteApp: (id: number) => void;
updateAppHandler: (app: App) => void;
reorderApps: (apps: App[]) => void;
updateConfig: (formData: any) => void;
createNotification: (notification: NewNotification) => void;
interface Props {
openFormForUpdating: (app: App) => void;
}
const AppTable = (props: ComponentProps): JSX.Element => {
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[]>([]);
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
// Copy apps array
useEffect(() => {
setLocalApps([...props.apps]);
}, [props.apps]);
// Check ordering
useEffect(() => {
const order = props.config.useOrdering;
if (order === 'orderId') {
setIsCustomOrder(true);
}
}, []);
const deleteAppHandler = (app: App): void => {
const proceed = window.confirm(
`Are you sure you want to delete ${app.name} at ${app.url} ?`
);
if (proceed) {
props.deleteApp(app.id);
}
};
// Support keyboard navigation for actions
const keyboardActionHandler = (
e: KeyboardEvent,
app: App,
handler: Function
) => {
if (e.key === 'Enter') {
handler(app);
}
};
setLocalApps([...apps]);
}, [apps]);
const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) {
props.createNotification({
if (config.useOrdering !== 'orderId') {
createNotification({
title: 'Error',
message: 'Custom order is disabled',
});
@ -95,26 +59,51 @@ const AppTable = (props: ComponentProps): JSX.Element => {
tmpApps.splice(result.destination.index, 0, movedApp);
setLocalApps(tmpApps);
props.reorderApps(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) {
deleteApp(id);
}
};
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 (
<Fragment>
<div className={classes.Message}>
{isCustomOrder ? (
<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{' '}
<Link to="/settings/other">settings</Link>
Custom order is disabled. You can change it in the{' '}
<Link to="/settings/general">settings</Link>
</p>
)}
</div>
</Message>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="apps">
{(provided) => (
<Table
headers={['Name', 'URL', 'Icon', 'Actions']}
headers={['Name', 'URL', 'Icon', 'Visibility', 'Actions']}
innerRef={provided.innerRef}
>
{localApps.map((app: App, index): JSX.Element => {
@ -143,54 +132,18 @@ const AppTable = (props: ComponentProps): JSX.Element => {
<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 && (
<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>
<TableActions
entity={app}
deleteHandler={deleteAppHandler}
updateHandler={updateAppHandler}
pinHanlder={pinAppHandler}
changeVisibilty={changeAppVisibiltyHandler}
/>
)}
</tr>
);
@ -205,20 +158,3 @@ const AppTable = (props: ComponentProps): JSX.Element => {
</Fragment>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
apps: state.app.apps,
config: state.config.config,
};
};
const actions = {
pinApp,
deleteApp,
reorderApps,
updateConfig,
createNotification,
};
export default connect(mapStateToProps, actions)(AppTable);

View file

@ -2,81 +2,79 @@ 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 { getApps, apps, loading, searching = false } = props;
export const Apps = (props: Props): JSX.Element => {
// Get Redux state
const {
apps: { apps, loading },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
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,
orderId: 0,
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
// Get Redux action creators
const dispatch = useDispatch();
const { getApps, setEditApp } = bindActionCreators(actionCreators, dispatch);
// Load apps if array is empty
useEffect(() => {
if (apps.length === 0) {
if (!apps.length) {
getApps();
}
}, [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
@ -84,29 +82,29 @@ const Apps = (props: ComponentProps): JSX.Element => {
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}>
{loading ? (
<Spinner />
) : !isInEdit ? (
<AppGrid apps={apps} searching />
) : !showTable ? (
<AppGrid apps={apps} searching={props.searching} />
) : (
<AppTable updateAppHandler={toggleUpdate} />
<AppTable openFormForUpdating={openFormForUpdating} />
)}
</div>
</Container>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
apps: state.app.apps,
loading: state.app.loading,
};
};
export default connect(mapStateToProps, { getApps })(Apps);

View file

@ -10,6 +10,10 @@
text-transform: uppercase;
}
.BookmarkHeader:hover {
cursor: pointer;
}
.Bookmarks {
display: flex;
flex-direction: column;

View file

@ -1,22 +1,52 @@
import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
import classes from './BookmarkCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser, urlParser } from '../../../utility';
import { Fragment } from 'react';
import { connect } from 'react-redux';
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;
config: Config;
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) => {
{category.bookmarks.map((bookmark: Bookmark) => {
const redirectUrl = urlParser(bookmark.url)[1];
let iconEl: JSX.Element = <Fragment></Fragment>;
@ -24,21 +54,25 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
if (bookmark.icon) {
const { icon, name } = bookmark;
if (/.(jpeg|jpg|png)$/i.test(icon)) {
if (isImage(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = (
<div className={classes.BookmarkIcon}>
<img
src={`/uploads/${icon}`}
src={source}
alt={`${name} icon`}
className={classes.CustomIcon}
/>
</div>
);
} else if (/.(svg)$/i.test(icon)) {
} else if (isSvg(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = (
<div className={classes.BookmarkIcon}>
<svg
data-src={`/uploads/${icon}`}
data-src={source}
fill="var(--color-primary)"
className={classes.BookmarkIconSvg}
></svg>
@ -56,7 +90,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
return (
<a
href={redirectUrl}
target={props.config.bookmarksSameTab ? '' : '_blank'}
target={config.bookmarksSameTab ? '' : '_blank'}
rel="noreferrer"
key={`bookmark-${bookmark.id}`}
>
@ -69,11 +103,3 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
</div>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(BookmarkCard);

View file

@ -1,363 +0,0 @@
// React
import {
useState,
SyntheticEvent,
Fragment,
ChangeEvent,
useEffect,
} from 'react';
// Redux
import { connect } from 'react-redux';
import {
getCategories,
addCategory,
addBookmark,
updateCategory,
updateBookmark,
createNotification,
} from '../../../store/actions';
// Typescript
import {
Bookmark,
Category,
GlobalState,
NewBookmark,
NewCategory,
NewNotification,
} from '../../../interfaces';
import { ContentType } from '../Bookmarks';
// UI
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
// CSS
import classes from './BookmarkForm.module.css';
interface ComponentProps {
modalHandler: () => void;
contentType: ContentType;
categories: Category[];
category?: Category;
bookmark?: Bookmark;
addCategory: (formData: NewCategory) => void;
addBookmark: (formData: NewBookmark | FormData) => void;
updateCategory: (id: number, formData: NewCategory) => void;
updateBookmark: (
id: number,
formData: NewBookmark | FormData,
category: {
prev: number;
curr: number;
}
) => void;
createNotification: (notification: NewNotification) => void;
}
const BookmarkForm = (props: ComponentProps): JSX.Element => {
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [categoryName, setCategoryName] = useState<NewCategory>({
name: '',
});
const [formData, setFormData] = useState<NewBookmark>({
name: '',
url: '',
categoryId: -1,
icon: '',
});
// Load category data if provided for editing
useEffect(() => {
if (props.category) {
setCategoryName({ name: props.category.name });
} else {
setCategoryName({ name: '' });
}
}, [props.category]);
// Load bookmark data if provided for editing
useEffect(() => {
if (props.bookmark) {
setFormData({
name: props.bookmark.name,
url: props.bookmark.url,
categoryId: props.bookmark.categoryId,
icon: props.bookmark.icon,
});
} else {
setFormData({
name: '',
url: '',
categoryId: -1,
icon: '',
});
}
}, [props.bookmark]);
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
const createFormData = (): FormData => {
const data = new FormData();
if (customIcon) {
data.append('icon', customIcon);
}
data.append('name', formData.name);
data.append('url', formData.url);
data.append('categoryId', `${formData.categoryId}`);
return data;
};
if (!props.category && !props.bookmark) {
// Add new
if (props.contentType === ContentType.category) {
// 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;
}
if (customIcon) {
const data = createFormData();
props.addBookmark(data);
} else {
props.addBookmark(formData);
}
setFormData({
name: '',
url: '',
categoryId: formData.categoryId,
icon: '',
});
// setCustomIcon(null);
}
} 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
if (customIcon) {
const data = createFormData();
props.updateBookmark(props.bookmark.id, data, {
prev: props.bookmark.categoryId,
curr: formData.categoryId,
});
} else {
props.updateBookmark(props.bookmark.id, formData, {
prev: props.bookmark.categoryId,
curr: formData.categoryId,
});
}
setFormData({
name: '',
url: '',
categoryId: -1,
icon: '',
});
setCustomIcon(null);
}
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),
});
};
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
if (e.target.files) {
setCustomIcon(e.target.files[0]);
}
};
let button = <Button>Submit</Button>;
if (!props.category && !props.bookmark) {
if (props.contentType === ContentType.category) {
button = <Button>Add new category</Button>;
} else {
button = <Button>Add new bookmark</Button>;
}
} else if (props.category) {
button = <Button>Update category</Button>;
} else if (props.bookmark) {
button = <Button>Update bookmark</Button>;
}
return (
<ModalForm
modalHandler={props.modalHandler}
formHandler={formSubmitHandler}
>
{props.contentType === ContentType.category ? (
<Fragment>
<InputGroup>
<label htmlFor="categoryName">Category Name</label>
<input
type="text"
name="categoryName"
id="categoryName"
placeholder="Social Media"
required
value={categoryName.name}
onChange={(e) => setCategoryName({ name: e.target.value })}
/>
</InputGroup>
</Fragment>
) : (
<Fragment>
<InputGroup>
<label htmlFor="name">Bookmark Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Reddit"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="url">Bookmark URL</label>
<input
type="text"
name="url"
id="url"
placeholder="reddit.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target="_blank"
rel="noreferrer"
>
{' '}
Check supported URL formats
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor="categoryId">Bookmark Category</label>
<select
name="categoryId"
id="categoryId"
required
onChange={(e) => selectChangeHandler(e)}
value={formData.categoryId}
>
<option value={-1}>Select category</option>
{props.categories.map((category: Category): JSX.Element => {
return (
<option key={category.id} value={category.id}>
{category.name}
</option>
);
})}
</select>
</InputGroup>
{!useCustomIcon ? (
// mdi
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="text"
name="icon"
id="icon"
placeholder="book-open-outline"
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI.
<a href="https://materialdesignicons.com/" target="blank">
{' '}
Click here for reference
</a>
</span>
<span
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
className={classes.Switch}
>
Switch to custom icon upload
</span>
</InputGroup>
) : (
// custom
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="file"
name="icon"
id="icon"
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg"
/>
<span
onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch}
>
Switch to MDI
</span>
</InputGroup>
)}
</Fragment>
)}
{button}
</ModalForm>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
categories: state.bookmark.categories,
};
};
const dispatchMap = {
getCategories,
addCategory,
addBookmark,
updateCategory,
updateBookmark,
createNotification,
};
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);

View file

@ -20,12 +20,3 @@
grid-template-columns: repeat(4, 1fr);
}
}
.BookmarksMessage {
color: var(--color-primary);
}
.BookmarksMessage a {
color: var(--color-accent);
font-weight: 600;
}

View file

@ -4,54 +4,61 @@ import classes from './BookmarkGrid.module.css';
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) {
if (props.searching && props.categories[0].bookmarks.length === 0) {
bookmarks = (
<p className={classes.BookmarksMessage}>
No bookmarks match your search criteria
</p>
);
if (categories.length) {
if (searching && !categories[0].bookmarks.length) {
bookmarks = <Message>No bookmarks match your search criteria</Message>;
} else {
bookmarks = (
<div className={classes.BookmarkGrid}>
{props.categories.map(
{categories.map(
(category: Category): JSX.Element => (
<BookmarkCard category={category} key={category.id} />
<BookmarkCard
category={category}
fromHomepage={fromHomepage}
key={category.id}
/>
)
)}
</div>
);
}
} else {
if (props.totalCategories) {
if (totalCategories) {
bookmarks = (
<p className={classes.BookmarksMessage}>
<Message>
There are no pinned categories. You can pin them from the{' '}
<Link to="/bookmarks">/bookmarks</Link> menu
</p>
</Message>
);
} else {
bookmarks = (
<p className={classes.BookmarksMessage}>
<Message>
You don't have any bookmarks. You can add a new one from{' '}
<Link to="/bookmarks">/bookmarks</Link> menu
</p>
</Message>
);
}
}
return bookmarks;
};
export default BookmarkGrid;

View file

@ -1,29 +0,0 @@
.TableActions {
display: flex;
align-items: center;
}
.TableAction {
width: 22px;
}
.TableAction:hover {
cursor: pointer;
}
.Message {
width: 100%;
display: flex;
justify-content: center;
align-items: baseline;
color: var(--color-primary);
margin-bottom: 20px;
}
.Message a {
color: var(--color-accent);
}
.Message a:hover {
cursor: pointer;
}

View file

@ -1,283 +0,0 @@
import { KeyboardEvent, useState, useEffect, Fragment } from 'react';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from 'react-beautiful-dnd';
import { Link } from 'react-router-dom';
// Redux
import { connect } from 'react-redux';
import {
pinCategory,
deleteCategory,
deleteBookmark,
createNotification,
reorderCategories,
} from '../../../store/actions';
// Typescript
import {
Bookmark,
Category,
Config,
GlobalState,
NewNotification,
} from '../../../interfaces';
import { ContentType } from '../Bookmarks';
// CSS
import classes from './BookmarkTable.module.css';
// UI
import Table from '../../UI/Table/Table';
import Icon from '../../UI/Icons/Icon/Icon';
interface ComponentProps {
contentType: ContentType;
categories: Category[];
config: Config;
pinCategory: (category: Category) => void;
deleteCategory: (id: number) => void;
updateHandler: (data: Category | Bookmark) => void;
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
createNotification: (notification: NewNotification) => void;
reorderCategories: (categories: Category[]) => void;
}
const BookmarkTable = (props: ComponentProps): JSX.Element => {
const [localCategories, setLocalCategories] = useState<Category[]>([]);
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
// Copy categories array
useEffect(() => {
setLocalCategories([...props.categories]);
}, [props.categories]);
// Check ordering
useEffect(() => {
const order = props.config.useOrdering;
if (order === 'orderId') {
setIsCustomOrder(true);
}
});
const deleteCategoryHandler = (category: Category): void => {
const 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);
}
};
const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) {
props.createNotification({
title: 'Error',
message: 'Custom order is disabled',
});
return;
}
if (!result.destination) {
return;
}
const tmpCategories = [...localCategories];
const [movedApp] = tmpCategories.splice(result.source.index, 1);
tmpCategories.splice(result.destination.index, 0, movedApp);
setLocalCategories(tmpCategories);
props.reorderCategories(tmpCategories);
};
if (props.contentType === ContentType.category) {
return (
<Fragment>
<div className={classes.Message}>
{isCustomOrder ? (
<p>You can drag and drop single rows to reorder categories</p>
) : (
<p>
Custom order is disabled. You can change it in{' '}
<Link to="/settings/other">settings</Link>
</p>
)}
</div>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="categories">
{(provided) => (
<Table headers={['Name', 'Actions']} innerRef={provided.innerRef}>
{localCategories.map(
(category: Category, index): JSX.Element => {
return (
<Draggable
key={category.id}
draggableId={category.id.toString()}
index={index}
>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging
? '1px solid var(--color-accent)'
: 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
return (
<tr
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={style}
>
<td>{category.name}</td>
{!snapshot.isDragging && (
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() =>
deleteCategoryHandler(category)
}
onKeyDown={(e) =>
keyboardActionHandler(
e,
category,
deleteCategoryHandler
)
}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
<div
className={classes.TableAction}
onClick={() =>
props.updateHandler(category)
}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
<div
className={classes.TableAction}
onClick={() => props.pinCategory(category)}
onKeyDown={(e) =>
keyboardActionHandler(
e,
category,
props.pinCategory
)
}
tabIndex={0}
>
{category.isPinned ? (
<Icon
icon="mdiPinOff"
color="var(--color-accent)"
/>
) : (
<Icon icon="mdiPin" />
)}
</div>
</td>
)}
</tr>
);
}}
</Draggable>
);
}
)}
</Table>
)}
</Droppable>
</DragDropContext>
</Fragment>
);
} else {
const bookmarks: { bookmark: Bookmark; categoryName: string }[] = [];
props.categories.forEach((category: Category) => {
category.bookmarks.forEach((bookmark: Bookmark) => {
bookmarks.push({
bookmark,
categoryName: category.name,
});
});
});
return (
<Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}>
{bookmarks.map(
(bookmark: { bookmark: Bookmark; categoryName: string }) => {
return (
<tr key={bookmark.bookmark.id}>
<td>{bookmark.bookmark.name}</td>
<td>{bookmark.bookmark.url}</td>
<td>{bookmark.bookmark.icon}</td>
<td>{bookmark.categoryName}</td>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateHandler(bookmark.bookmark)}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
</td>
</tr>
);
}
)}
</Table>
);
}
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
const actions = {
pinCategory,
deleteCategory,
deleteBookmark,
createNotification,
reorderCategories,
};
export default connect(mapStateToProps, actions)(BookmarkTable);

View file

@ -1,25 +1,34 @@
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;
}
@ -28,145 +37,159 @@ export enum ContentType {
bookmark,
}
const Bookmarks = (props: ComponentProps): JSX.Element => {
const { getCategories, categories, loading, searching = false } = props;
export const Bookmarks = (props: Props): JSX.Element => {
// Get Redux state
const {
bookmarks: { loading, categories, categoryInEdit },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
// Get Redux action creators
const dispatch = useDispatch();
const { getCategories, setEditCategory, setEditBookmark } =
bindActionCreators(actionCreators, dispatch);
// Load categories if array is empty
useEffect(() => {
if (!categories.length) {
getCategories();
}
}, []);
// Form
const [modalIsOpen, setModalIsOpen] = useState(false);
const [formContentType, setFormContentType] = useState(ContentType.category);
const [isInEdit, setIsInEdit] = useState(false);
const [isInUpdate, setIsInUpdate] = useState(false);
// Table
const [showTable, setShowTable] = useState(false);
const [tableContentType, setTableContentType] = useState(
ContentType.category
);
const [isInUpdate, setIsInUpdate] = useState(false);
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
name: '',
id: -1,
isPinned: false,
orderId: 0,
bookmarks: [],
createdAt: new Date(),
updatedAt: new Date(),
});
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
name: '',
url: '',
categoryId: -1,
icon: '',
id: -1,
createdAt: new Date(),
updatedAt: new Date(),
});
// Observe if user is authenticated -> set default view (grid) if not
useEffect(() => {
if (!isAuthenticated) {
setShowTable(false);
setModalIsOpen(false);
}
}, [isAuthenticated]);
useEffect(() => {
if (categories.length === 0) {
getCategories();
if (categoryInEdit && !modalIsOpen) {
setTableContentType(ContentType.bookmark);
setShowTable(true);
}
}, [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);
const openFormForUpdating = (data: Category | Bookmark): void => {
setIsInUpdate(true);
const instanceOfCategory = (object: any): object is Category => {
return 'bookmarks' in object;
};
if (instanceOfCategory(data)) {
setFormContentType(ContentType.category);
setEditCategory(data);
} else {
setIsInEdit(true);
setFormContentType(ContentType.bookmark);
setEditBookmark(data);
}
toggleModal();
};
// Table actions
const showTableForEditing = (contentType: ContentType) => {
// We're in the edit mode and the same button was clicked - go back to list
if (showTable && contentType === tableContentType) {
setEditCategory(null);
setShowTable(false);
} else {
setShowTable(true);
setTableContentType(contentType);
}
};
const instanceOfCategory = (object: any): object is Category => {
return 'bookmarks' in object;
};
const goToUpdateMode = (data: Category | Bookmark): void => {
setIsInUpdate(true);
if (instanceOfCategory(data)) {
setFormContentType(ContentType.category);
setCategoryInUpdate(data);
} else {
setFormContentType(ContentType.bookmark);
setBookmarkInUpdate(data);
}
toggleModal();
const finishEditing = () => {
setShowTable(false);
setEditCategory(null);
};
return (
<Container>
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
{!isInUpdate ? (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
/>
) : formContentType === ContentType.category ? (
<BookmarkForm
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>
{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 />
) : !isInEdit ? (
<BookmarkGrid categories={categories} searching />
) : !showTable ? (
<BookmarkGrid categories={categories} searching={props.searching} />
) : (
<BookmarkTable
<Table
contentType={tableContentType}
categories={categories}
updateHandler={goToUpdateMode}
openFormForUpdating={openFormForUpdating}
/>
)}
</Container>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.bookmark.loading,
categories: state.bookmark.categories,
};
};
export default connect(mapStateToProps, { getCategories })(Bookmarks);

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

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

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

View 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&nbsp;<span>{categoryInEdit.name}</span>
&nbsp;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>
);
};

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

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

View file

@ -1,17 +1,25 @@
import { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Config, GlobalState } from '../../../interfaces';
import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget';
import { getDateTime } from './functions/getDateTime';
import { greeter } from './functions/greeter';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
// CSS
import classes from './Header.module.css';
interface Props {
config: Config;
}
// 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 Header = (props: Props): JSX.Element => {
const [dateTime, setDateTime] = useState<string>(getDateTime());
const [greeting, setGreeting] = useState<string>(greeter());
@ -28,22 +36,18 @@ const Header = (props: Props): JSX.Element => {
return (
<header className={classes.Header}>
<p>{dateTime}</p>
{(!hideDate || showTime) && <p>{dateTime}</p>}
<Link to="/settings" className={classes.SettingsLink}>
Go to Settings
</Link>
<span className={classes.HeaderMain}>
<h1>{greeting}</h1>
<WeatherWidget />
</span>
{!hideHeader && (
<span className={classes.HeaderMain}>
<h1>{greeting}</h1>
<WeatherWidget />
</span>
)}
</header>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(Header);

View file

@ -1,3 +1,5 @@
import { parseTime } from '../../../../utility';
export const getDateTime = (): string => {
const days = localStorage.getItem('daySchema')?.split(';') || [
'Sunday',
@ -27,14 +29,43 @@ export const getDateTime = (): string => {
const now = new Date();
const useAmericanDate = localStorage.useAmericanDate === 'true';
const showTime = localStorage.showTime === 'true';
const hideDate = localStorage.hideDate === 'true';
if (!useAmericanDate) {
return `${days[now.getDay()]}, ${now.getDate()} ${
months[now.getMonth()]
} ${now.getFullYear()}`;
} else {
return `${days[now.getDay()]}, ${
months[now.getMonth()]
} ${now.getDate()} ${now.getFullYear()}`;
// 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}`;
};

View file

@ -2,47 +2,42 @@ 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, Config } from '../../interfaces';
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 SearchBar from '../SearchBar/SearchBar';
import Header from './Header/Header';
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[];
config: Config;
}
// Utils
import { escapeRegex } from '../../utility';
const Home = (props: ComponentProps): JSX.Element => {
export const Home = (): JSX.Element => {
const {
getApps,
apps,
appsLoading,
getCategories,
categories,
categoriesLoading,
} = props;
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);
@ -56,20 +51,24 @@ const Home = (props: ComponentProps): JSX.Element => {
if (!apps.length) {
getApps();
}
}, [getApps]);
}, []);
// Load bookmark categories
useEffect(() => {
if (!categories.length) {
getCategories();
}
}, [getCategories]);
}, []);
useEffect(() => {
if (localSearch) {
// Search through apps
setAppSearchResult([
...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)),
...apps.filter(({ name, description }) =>
new RegExp(escapeRegex(localSearch), 'i').test(
`${name} ${description}`
)
),
]);
// Search through bookmarks
@ -79,7 +78,9 @@ const Home = (props: ComponentProps): JSX.Element => {
category.bookmarks = categories
.map(({ bookmarks }) => bookmarks)
.flat()
.filter(({ name }) => new RegExp(localSearch, 'i').test(name));
.filter(({ name }) =>
new RegExp(escapeRegex(localSearch), 'i').test(name)
);
setBookmarkSearchResult([category]);
} else {
@ -90,7 +91,7 @@ const Home = (props: ComponentProps): JSX.Element => {
return (
<Container>
{!props.config.hideSearch ? (
{!config.hideSearch ? (
<SearchBar
setLocalSearch={setLocalSearch}
appSearchResult={appSearchResult}
@ -100,9 +101,20 @@ const Home = (props: ComponentProps): JSX.Element => {
<div></div>
)}
{!props.config.hideHeader ? <Header /> : <div></div>}
<Header />
{!props.config.hideApps ? (
{!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>
) : (
<></>
)}
{!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? (
<Fragment>
<SectionHeadline title="Applications" link="/applications" />
{appsLoading ? (
@ -121,28 +133,32 @@ const Home = (props: ComponentProps): JSX.Element => {
<div className={classes.HomeSpace}></div>
</Fragment>
) : (
<div></div>
<></>
)}
{!props.config.hideCategories ? (
{!config.hideCategories &&
(isAuthenticated || categories.some((c) => c.isPinned)) ? (
<Fragment>
<SectionHeadline title="Bookmarks" link="/bookmarks" />
{categoriesLoading ? (
{bookmarksLoading ? (
<Spinner />
) : (
<BookmarkGrid
categories={
!bookmarkSearchResult
? categories.filter(({ isPinned }) => isPinned)
? categories.filter(
({ isPinned, bookmarks }) => isPinned && bookmarks.length
)
: bookmarkSearchResult
}
totalCategories={categories.length}
searching={!!localSearch}
fromHomepage={true}
/>
)}
</Fragment>
) : (
<div></div>
<></>
)}
<Link to="/settings" className={classes.SettingsButton}>
@ -151,15 +167,3 @@ const Home = (props: ComponentProps): JSX.Element => {
</Container>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
appsLoading: state.app.loading,
apps: state.app.apps,
categoriesLoading: state.bookmark.loading,
categories: state.bookmark.categories,
config: state.config.config,
};
};
export default connect(mapStateToProps, { getApps, getCategories })(Home);

View file

@ -1,21 +1,20 @@
import { connect } from 'react-redux';
import { GlobalState, Notification as _Notification } from '../../interfaces';
import { useSelector } from 'react-redux';
import { Notification as NotificationInterface } from '../../interfaces';
import classes from './NotificationCenter.module.css';
import Notification from '../UI/Notification/Notification';
import { Notification } from '../UI';
import { State } from '../../store/reducers';
interface ComponentProps {
notifications: _Notification[];
}
export const NotificationCenter = (): JSX.Element => {
const { notifications } = useSelector((state: State) => state.notification);
const NotificationCenter = (props: ComponentProps): JSX.Element => {
return (
<div
className={classes.NotificationCenter}
style={{ height: `${props.notifications.length * 75}px` }}
style={{ height: `${notifications.length * 75}px` }}
>
{props.notifications.map((notification: _Notification) => {
{notifications.map((notification: NotificationInterface) => {
return (
<Notification
title={notification.title}
@ -29,11 +28,3 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
</div>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
notifications: state.notification.notifications,
};
};
export default connect(mapStateToProps)(NotificationCenter);

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

View file

@ -9,6 +9,7 @@
border-bottom: 2px solid var(--color-accent);
opacity: 0.5;
transition: all 0.2s;
border-radius: 0px;
}
.SearchBar:focus {

View file

@ -1,42 +1,33 @@
import { useRef, useEffect, KeyboardEvent } from 'react';
// Redux
import { connect } from 'react-redux';
import { createNotification } from '../../store/actions';
import { useDispatch, useSelector } from 'react-redux';
// Typescript
import {
App,
Category,
Config,
GlobalState,
NewNotification,
} from '../../interfaces';
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 ComponentProps {
createNotification: (notification: NewNotification) => void;
interface Props {
setLocalSearch: (query: string) => void;
appSearchResult: App[] | null;
bookmarkSearchResult: Category[] | null;
config: Config;
loading: boolean;
}
const SearchBar = (props: ComponentProps): JSX.Element => {
const {
setLocalSearch,
createNotification,
config,
loading,
appSearchResult,
bookmarkSearchResult,
} = props;
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'));
@ -54,12 +45,17 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
if (key === 'Escape') {
clearSearch();
} else if (document.activeElement !== inputRef.current) {
if (key === '`') {
inputRef.current.focus();
clearSearch();
}
}
};
window.addEventListener('keydown', keyOutsideFocus);
window.addEventListener('keyup', keyOutsideFocus);
return () => window.removeEventListener('keydown', keyOutsideFocus);
return () => window.removeEventListener('keyup', keyOutsideFocus);
}, []);
const clearSearch = () => {
@ -68,16 +64,22 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
};
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
const { isLocal, search, query, isURL, sameTab } = searchParser(
inputRef.current.value
);
const {
isLocal,
encodedURL,
primarySearch,
secondarySearch,
isURL,
sameTab,
rawQuery,
} = searchParser(inputRef.current.value);
if (isLocal) {
setLocalSearch(search);
setLocalSearch(encodedURL);
}
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
if (!query.prefix) {
if (!primarySearch.prefix) {
// Prefix not found -> emit notification
createNotification({
title: 'Error',
@ -94,19 +96,21 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
} else if (bookmarkSearchResult?.[0]?.bookmarks?.length) {
redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab);
} else {
// no local results -> search the internet with the default search provider
let template = query.template;
// no local results -> search the internet with the default search provider if query is not empty
if (!/^ *$/.test(rawQuery)) {
let template = primarySearch.template;
if (query.prefix === 'l') {
template = 'https://duckduckgo.com/?q=';
if (primarySearch.prefix === 'l') {
template = secondarySearch.template;
}
const url = `${template}${encodedURL}`;
redirectUrl(url, sameTab);
}
const url = `${template}${search}`;
redirectUrl(url, sameTab);
}
} else {
// Valid query -> redirect to search results
const url = `${query.template}${search}`;
const url = `${primarySearch.template}${encodedURL}`;
redirectUrl(url, sameTab);
}
} else if (e.code === 'Escape') {
@ -126,12 +130,3 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
</div>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
loading: state.config.loading,
};
};
export default connect(mapStateToProps, { createNotification })(SearchBar);

View file

@ -1,8 +1,14 @@
.AppVersion {
.text {
color: var(--color-primary);
margin-bottom: 15px;
}
.AppVersion a {
.text a,
.text span {
color: var(--color-accent);
}
}
.separator {
margin: 30px 0;
border: 1px solid var(--color-primary);
}

View file

@ -1,34 +1,57 @@
import { Fragment } from 'react';
// UI
import { Button, SettingsHeadline } from '../../UI';
import { AuthForm } from './AuthForm/AuthForm';
import classes from './AppDetails.module.css';
import Button from '../../UI/Buttons/Button/Button';
// Store
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
// Other
import { checkVersion } from '../../../utility';
const AppDetails = (): JSX.Element => {
export const AppDetails = (): JSX.Element => {
const { isAuthenticated } = useSelector((state: State) => state.auth);
return (
<Fragment>
<p className={classes.AppVersion}>
<a
href='https://github.com/pawelmalak/flame'
target='_blank'
rel='noreferrer'>
Flame
</a>
{' '}
version {process.env.REACT_APP_VERSION}
</p>
<p className={classes.AppVersion}>
See changelog {' '}
<a
href='https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md'
target='_blank'
rel='noreferrer'>
here
</a>
</p>
<Button click={() => checkVersion(true)}>Check for updates</Button>
</Fragment>
)
}
<SettingsHeadline text="Authentication" />
<AuthForm />
export default AppDetails;
{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>
);
};

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

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

View file

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

View file

@ -1,20 +1,26 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { Query } from '../../../../interfaces';
import Button from '../../../UI/Buttons/Button/Button';
import InputGroup from '../../../UI/Forms/InputGroup/InputGroup';
import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
import { connect } from 'react-redux';
import { addQuery, updateQuery } from '../../../../store/actions';
import { Button, InputGroup, ModalForm } from '../../../UI';
interface Props {
modalHandler: () => void;
addQuery: (query: Query) => {};
updateQuery: (query: Query, Oldprefix: string) => {};
query?: Query;
}
const QueriesForm = (props: Props): JSX.Element => {
const { modalHandler, addQuery, updateQuery, query } = props;
export const QueriesForm = (props: Props): JSX.Element => {
const dispatch = useDispatch();
const { addQuery, updateQuery } = bindActionCreators(
actionCreators,
dispatch
);
const { modalHandler, query } = props;
const [formData, setFormData] = useState<Query>({
name: '',
@ -77,6 +83,7 @@ const QueriesForm = (props: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="name">Prefix</label>
<input
@ -89,6 +96,7 @@ const QueriesForm = (props: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="name">Query Template</label>
<input
@ -101,9 +109,8 @@ const QueriesForm = (props: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
</ModalForm>
);
};
export default connect(null, { addQuery, updateQuery })(QueriesForm);

View file

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

View file

@ -1,356 +0,0 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
// Redux
import { connect } from 'react-redux';
import {
createNotification,
updateConfig,
sortApps,
sortCategories,
} from '../../../store/actions';
// Typescript
import {
Config,
GlobalState,
NewNotification,
OtherSettingsForm,
} from '../../../interfaces';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
// Utils
import { otherSettingsTemplate, inputHandler } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: OtherSettingsForm) => void;
sortApps: () => void;
sortCategories: () => void;
loading: boolean;
config: Config;
}
const OtherSettings = (props: ComponentProps): JSX.Element => {
const { config } = props;
// Initial state
const [formData, setFormData] = useState<OtherSettingsForm>(
otherSettingsTemplate
);
// Get config
useEffect(() => {
setFormData({
...config,
});
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await props.updateConfig(formData);
// Update local page title
document.title = formData.customTitle;
// Sort apps and categories with new settings
props.sortApps();
props.sortCategories();
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<OtherSettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
{/* OTHER OPTIONS */}
<SettingsHeadline text="Miscellaneous" />
{/* PAGE TITLE */}
<InputGroup>
<label htmlFor="customTitle">Custom page title</label>
<input
type="text"
id="customTitle"
name="customTitle"
placeholder="Flame"
value={formData.customTitle}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* DATE FORMAT */}
<InputGroup>
<label htmlFor="useAmericanDate">Date formatting</label>
<select
id="useAmericanDate"
name="useAmericanDate"
value={formData.useAmericanDate ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Friday, October 22 2021</option>
<option value={0}>Friday, 22 October 2021</option>
</select>
</InputGroup>
{/* BEAHVIOR OPTIONS */}
<SettingsHeadline text="App Behavior" />
{/* PIN APPS */}
<InputGroup>
<label htmlFor="pinAppsByDefault">
Pin new applications by default
</label>
<select
id="pinAppsByDefault"
name="pinAppsByDefault"
value={formData.pinAppsByDefault ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* PIN CATEGORIES */}
<InputGroup>
<label htmlFor="pinCategoriesByDefault">
Pin new categories by default
</label>
<select
id="pinCategoriesByDefault"
name="pinCategoriesByDefault"
value={formData.pinCategoriesByDefault ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* SORT TYPE */}
<InputGroup>
<label htmlFor="useOrdering">Sorting type</label>
<select
id="useOrdering"
name="useOrdering"
value={formData.useOrdering}
onChange={(e) => inputChangeHandler(e)}
>
<option value="createdAt">By creation date</option>
<option value="name">Alphabetical order</option>
<option value="orderId">Custom order</option>
</select>
</InputGroup>
{/* APPS OPPENING */}
<InputGroup>
<label htmlFor="appsSameTab">Open applications in the same tab</label>
<select
id="appsSameTab"
name="appsSameTab"
value={formData.appsSameTab ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* BOOKMARKS OPPENING */}
<InputGroup>
<label htmlFor="bookmarksSameTab">Open bookmarks in the same tab</label>
<select
id="bookmarksSameTab"
name="bookmarksSameTab"
value={formData.bookmarksSameTab ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* MODULES OPTIONS */}
<SettingsHeadline text="Modules" />
{/* HIDE HEADER */}
<InputGroup>
<label htmlFor="hideHeader">Hide greeting and date</label>
<select
id="hideHeader"
name="hideHeader"
value={formData.hideHeader ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* CUSTOM GREETINGS */}
<InputGroup>
<label htmlFor="greetingsSchema">Custom greetings</label>
<input
type="text"
id="greetingsSchema"
name="greetingsSchema"
placeholder="Good day;Hi;Bye!"
value={formData.greetingsSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Greetings must be separated with semicolon. Only 4 messages can be
used
</span>
</InputGroup>
{/* CUSTOM DAYS */}
<InputGroup>
<label htmlFor="daySchema">Custom weekday names</label>
<input
type="text"
id="daySchema"
name="daySchema"
placeholder="Sunday;Monday;Tuesday"
value={formData.daySchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* CUSTOM MONTHS */}
<InputGroup>
<label htmlFor="monthSchema">Custom month names</label>
<input
type="text"
id="monthSchema"
name="monthSchema"
placeholder="January;February;March"
value={formData.monthSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* HIDE APPS */}
<InputGroup>
<label htmlFor="hideApps">Hide applications</label>
<select
id="hideApps"
name="hideApps"
value={formData.hideApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE CATEGORIES */}
<InputGroup>
<label htmlFor="hideCategories">Hide categories</label>
<select
id="hideCategories"
name="hideCategories"
value={formData.hideCategories ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* DOCKER SETTINGS */}
<SettingsHeadline text="Docker" />
{/* CUSTOM DOCKER SOCKET HOST */}
<InputGroup>
<label htmlFor="dockerHost">Docker Host</label>
<input
type="text"
id="dockerHost"
name="dockerHost"
placeholder="dockerHost:port"
value={formData.dockerHost}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* USE DOCKER API */}
<InputGroup>
<label htmlFor="dockerApps">Use Docker API</label>
<select
id="dockerApps"
name="dockerApps"
value={formData.dockerApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* UNPIN DOCKER APPS */}
<InputGroup>
<label htmlFor="unpinStoppedApps">
Unpin stopped containers / other apps
</label>
<select
id="unpinStoppedApps"
name="unpinStoppedApps"
value={formData.unpinStoppedApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* KUBERNETES SETTINGS */}
<SettingsHeadline text="Kubernetes" />
{/* USE KUBERNETES */}
<InputGroup>
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
<select
id="kubernetesApps"
name="kubernetesApps"
value={formData.kubernetesApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading,
config: state.config.config,
};
};
const actions = {
createNotification,
updateConfig,
sortApps,
sortCategories,
};
export default connect(mapStateToProps, actions)(OtherSettings);

View file

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

View file

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

View file

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

View file

@ -1,6 +1,9 @@
//
import { NavLink, Link, Switch, Route } from 'react-router-dom';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../store/reducers';
// Typescript
import { Route as SettingsRoute } from '../../interfaces';
@ -8,28 +11,35 @@ import { Route as SettingsRoute } from '../../interfaces';
import classes from './Settings.module.css';
// Components
import Themer from '../Themer/Themer';
import WeatherSettings from './WeatherSettings/WeatherSettings';
import OtherSettings from './OtherSettings/OtherSettings';
import AppDetails from './AppDetails/AppDetails';
import StyleSettings from './StyleSettings/StyleSettings';
import SearchSettings from './SearchSettings/SearchSettings';
import { Themer } from './Themer/Themer';
import { WeatherSettings } from './WeatherSettings/WeatherSettings';
import { UISettings } from './UISettings/UISettings';
import { AppDetails } from './AppDetails/AppDetails';
import { StyleSettings } from './StyleSettings/StyleSettings';
import { GeneralSettings } from './GeneralSettings/GeneralSettings';
import { DockerSettings } from './DockerSettings/DockerSettings';
import { ProtectedRoute } from '../Routing/ProtectedRoute';
// UI
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
import { Container, Headline } from '../UI';
// Data
import { routes } from './settings.json';
import clientRoutes from './settings.json';
export const Settings = (): JSX.Element => {
const routes = clientRoutes.routes;
const { isAuthenticated } = useSelector((state: State) => state.auth);
const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);
const Settings = (): JSX.Element => {
return (
<Container>
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
<div className={classes.Settings}>
{/* NAVIGATION MENU */}
<nav className={classes.SettingsNav}>
{routes.map(({ name, dest }: SettingsRoute, idx) => (
{tabs.map(({ name, dest }: SettingsRoute, idx) => (
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
@ -46,10 +56,20 @@ const Settings = (): JSX.Element => {
<section className={classes.SettingsContent}>
<Switch>
<Route exact path="/settings" component={Themer} />
<Route path="/settings/weather" component={WeatherSettings} />
<Route path="/settings/search" component={SearchSettings} />
<Route path="/settings/other" component={OtherSettings} />
<Route path="/settings/css" component={StyleSettings} />
<ProtectedRoute
path="/settings/weather"
component={WeatherSettings}
/>
<ProtectedRoute
path="/settings/general"
component={GeneralSettings}
/>
<ProtectedRoute path="/settings/interface" component={UISettings} />
<ProtectedRoute
path="/settings/docker"
component={DockerSettings}
/>
<ProtectedRoute path="/settings/css" component={StyleSettings} />
<Route path="/settings/app" component={AppDetails} />
</Switch>
</section>
@ -57,5 +77,3 @@ const Settings = (): JSX.Element => {
</Container>
);
};
export default Settings;

View file

@ -2,54 +2,60 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import axios from 'axios';
// Redux
import { connect } from 'react-redux';
import { createNotification } from '../../../store/actions';
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { ApiResponse, NewNotification } from '../../../interfaces';
import { ApiResponse } from '../../../interfaces';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
// Other
import { InputGroup, Button } from '../../UI';
import { applyAuth } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
}
export const StyleSettings = (): JSX.Element => {
const dispatch = useDispatch();
const { createNotification } = bindActionCreators(actionCreators, dispatch);
const StyleSettings = (props: ComponentProps): JSX.Element => {
const [customStyles, setCustomStyles] = useState<string>('');
useEffect(() => {
axios.get<ApiResponse<string>>('/api/config/0/css')
.then(data => setCustomStyles(data.data.data))
.catch(err => console.log(err.response));
}, [])
axios
.get<ApiResponse<string>>('/api/config/0/css')
.then((data) => setCustomStyles(data.data.data))
.catch((err) => console.log(err.response));
}, []);
const inputChangeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
e.preventDefault();
setCustomStyles(e.target.value);
}
};
const formSubmitHandler = (e: FormEvent) => {
e.preventDefault();
axios.put<ApiResponse<{}>>('/api/config/0/css', { styles: customStyles })
axios
.put<ApiResponse<{}>>(
'/api/config/0/css',
{ styles: customStyles },
{ headers: applyAuth() }
)
.then(() => {
props.createNotification({
createNotification({
title: 'Success',
message: 'CSS saved. Reload page to see changes'
})
message: 'CSS saved. Reload page to see changes',
});
})
.catch(err => console.log(err.response));
}
.catch((err) => console.log(err.response));
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<InputGroup>
<label htmlFor='customStyles'>Custom CSS</label>
<label htmlFor="customStyles">Custom CSS</label>
<textarea
id='customStyles'
name='customStyles'
id="customStyles"
name="customStyles"
value={customStyles}
onChange={(e) => inputChangeHandler(e)}
spellCheck={false}
@ -57,7 +63,5 @@ const StyleSettings = (props: ComponentProps): JSX.Element => {
</InputGroup>
<Button>Save CSS</Button>
</form>
)
}
export default connect(null, { createNotification })(StyleSettings);
);
};

View file

@ -0,0 +1,7 @@
.ThemeBuilder {
margin-bottom: 30px;
}
.Buttons button:not(:last-child) {
margin-right: 10px;
}

View file

@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
// Redux
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
// Other
import { Theme } from '../../../../interfaces';
// UI
import { Button, Modal } from '../../../UI';
import { ThemeGrid } from '../ThemeGrid/ThemeGrid';
import classes from './ThemeBuilder.module.css';
import { ThemeCreator } from './ThemeCreator';
import { ThemeEditor } from './ThemeEditor';
interface Props {
themes: Theme[];
}
export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
const {
auth: { isAuthenticated },
theme: { themeInEdit, userThemes },
} = useSelector((state: State) => state);
const { editTheme } = bindActionCreators(actionCreators, useDispatch());
const [showModal, toggleShowModal] = useState(false);
const [isInEdit, toggleIsInEdit] = useState(false);
useEffect(() => {
if (themeInEdit) {
toggleIsInEdit(false);
toggleShowModal(true);
}
}, [themeInEdit]);
useEffect(() => {
if (isInEdit && !userThemes.length) {
toggleIsInEdit(false);
toggleShowModal(false);
}
}, [userThemes]);
return (
<div className={classes.ThemeBuilder}>
{/* MODALS */}
<Modal
isOpen={showModal}
setIsOpen={() => toggleShowModal(!showModal)}
cb={() => editTheme(null)}
>
{isInEdit ? (
<ThemeEditor modalHandler={() => toggleShowModal(!showModal)} />
) : (
<ThemeCreator modalHandler={() => toggleShowModal(!showModal)} />
)}
</Modal>
{/* USER THEMES */}
<ThemeGrid themes={themes} />
{/* BUTTONS */}
{isAuthenticated && (
<div className={classes.Buttons}>
<Button
click={() => {
editTheme(null);
toggleIsInEdit(false);
toggleShowModal(!showModal);
}}
>
Create new theme
</Button>
{themes.length ? (
<Button
click={() => {
toggleIsInEdit(true);
toggleShowModal(!showModal);
}}
>
Edit user themes
</Button>
) : (
<></>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,5 @@
.ColorsContainer {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 10px;
}

View file

@ -0,0 +1,152 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
// UI
import { Button, InputGroup, ModalForm } from '../../../UI';
import classes from './ThemeCreator.module.css';
// Other
import { Theme } from '../../../../interfaces';
interface Props {
modalHandler: () => void;
}
export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
const {
theme: { activeTheme, themeInEdit },
} = useSelector((state: State) => state);
const { addTheme, updateTheme, editTheme } = bindActionCreators(
actionCreators,
useDispatch()
);
const [formData, setFormData] = useState<Theme>({
name: '',
isCustom: true,
colors: {
primary: '#ffffff',
accent: '#ffffff',
background: '#ffffff',
},
});
useEffect(() => {
setFormData({ ...formData, colors: activeTheme.colors });
}, [activeTheme]);
useEffect(() => {
if (themeInEdit) {
setFormData(themeInEdit);
}
}, [themeInEdit]);
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
};
const setColor = ({
target: { value, name },
}: ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
colors: {
...formData.colors,
[name]: value,
},
});
};
const closeModal = () => {
editTheme(null);
modalHandler();
};
const formHandler = (e: FormEvent) => {
e.preventDefault();
if (!themeInEdit) {
addTheme(formData);
} else {
updateTheme(formData, themeInEdit.name);
}
// close modal
closeModal();
// clear theme name
setFormData({ ...formData, name: '' });
};
return (
<ModalForm formHandler={formHandler} modalHandler={closeModal}>
<InputGroup>
<label htmlFor="name">Theme name</label>
<input
type="text"
name="name"
id="name"
placeholder="my_theme"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<div className={classes.ColorsContainer}>
<InputGroup>
<label htmlFor="primary">Primary color</label>
<input
type="color"
name="primary"
id="primary"
required
value={formData.colors.primary}
onChange={(e) => setColor(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="accent">Accent color</label>
<input
type="color"
name="accent"
id="accent"
required
value={formData.colors.accent}
onChange={(e) => setColor(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="background">Background color</label>
<input
type="color"
name="background"
id="background"
required
value={formData.colors.background}
onChange={(e) => setColor(e)}
/>
</InputGroup>
</div>
{!themeInEdit ? (
<Button>Add theme</Button>
) : (
<Button>Update theme</Button>
)}
</ModalForm>
);
};

View file

@ -0,0 +1,57 @@
import { Fragment } from 'react';
// Redux
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { Theme } from '../../../../interfaces';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
// Other
import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI';
interface Props {
modalHandler: () => void;
}
export const ThemeEditor = (props: Props): JSX.Element => {
const {
theme: { userThemes },
} = useSelector((state: State) => state);
const { deleteTheme, editTheme } = bindActionCreators(
actionCreators,
useDispatch()
);
const updateHandler = (theme: Theme) => {
props.modalHandler();
editTheme(theme);
};
const deleteHandler = (theme: Theme) => {
if (window.confirm(`Are you sure you want to delete this theme?`)) {
deleteTheme(theme.name);
}
};
return (
<ModalForm formHandler={() => {}} modalHandler={props.modalHandler}>
<CompactTable headers={['Name', 'Actions']}>
{userThemes.map((t, idx) => (
<Fragment key={idx}>
<span>{t.name}</span>
<ActionIcons>
<span onClick={() => updateHandler(t)}>
<Icon icon="mdiPencil" />
</span>
<span onClick={() => deleteHandler(t)}>
<Icon icon="mdiDelete" />
</span>
</ActionIcons>
</Fragment>
))}
</CompactTable>
</ModalForm>
);
};

View file

@ -15,4 +15,4 @@
.ThemerGrid {
grid-template-columns: 1fr 1fr 1fr;
}
}
}

View file

@ -0,0 +1,22 @@
// Components
import { ThemePreview } from '../ThemePreview/ThemePreview';
// Other
import { Theme } from '../../../../interfaces';
import classes from './ThemeGrid.module.css';
interface Props {
themes: Theme[];
}
export const ThemeGrid = ({ themes }: Props): JSX.Element => {
return (
<div className={classes.ThemerGrid}>
{themes.map(
(theme: Theme, idx: number): JSX.Element => (
<ThemePreview key={idx} theme={theme} />
)
)}
</div>
);
};

View file

@ -14,7 +14,6 @@
text-transform: capitalize;
margin: 8px 0;
color: var(--color-primary);
/* align-self: flex-start; */
}
.ColorsPreview {
@ -32,4 +31,4 @@
width: 40px;
height: 40px;
}
}
}

View file

@ -0,0 +1,38 @@
// Redux
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
// Other
import { Theme } from '../../../../interfaces/Theme';
import classes from './ThemePreview.module.css';
interface Props {
theme: Theme;
}
export const ThemePreview = ({
theme: { colors, name },
}: Props): JSX.Element => {
const { setTheme } = bindActionCreators(actionCreators, useDispatch());
return (
<div className={classes.ThemePreview} onClick={() => setTheme(colors)}>
<div className={classes.ColorsPreview}>
<div
className={classes.ColorPreview}
style={{ backgroundColor: colors.background }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: colors.primary }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: colors.accent }}
></div>
</div>
<p>{name}</p>
</div>
);
};

View file

@ -0,0 +1,105 @@
import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
// Typescript
import { Theme, ThemeSettingsForm } from '../../../interfaces';
// Components
import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
import { ThemeGrid } from './ThemeGrid/ThemeGrid';
// Other
import {
inputHandler,
parseThemeToPAB,
themeSettingsTemplate,
} from '../../../utility';
export const Themer = (): JSX.Element => {
const {
auth: { isAuthenticated },
config: { loading, config },
theme: { themes, userThemes },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
// Initial state
const [formData, setFormData] = useState<ThemeSettingsForm>(
themeSettingsTemplate
);
// Get config
useEffect(() => {
setFormData({
...config,
});
}, [loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await updateConfig({ ...formData });
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<ThemeSettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
const customThemesEl = (
<Fragment>
<SettingsHeadline text="User themes" />
<ThemeBuilder themes={userThemes} />
</Fragment>
);
return (
<Fragment>
<SettingsHeadline text="App themes" />
{!themes.length ? <Spinner /> : <ThemeGrid themes={themes} />}
{!userThemes.length ? isAuthenticated && customThemesEl : customThemesEl}
{isAuthenticated && (
<form onSubmit={formSubmitHandler}>
<SettingsHeadline text="Other settings" />
<InputGroup>
<label htmlFor="defaultTheme">Default theme for new users</label>
<select
id="defaultTheme"
name="defaultTheme"
value={formData.defaultTheme}
onChange={(e) => inputChangeHandler(e)}
>
{[...themes, ...userThemes].map((theme: Theme, idx) => (
<option key={idx} value={parseThemeToPAB(theme.colors)}>
{theme.isCustom && '+'} {theme.name}
</option>
))}
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
)}
</Fragment>
);
};

View file

@ -0,0 +1,243 @@
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 { UISettingsForm } from '../../../interfaces';
// UI
import { InputGroup, Button, SettingsHeadline } from '../../UI';
// Utils
import { uiSettingsTemplate, inputHandler } from '../../../utility';
export const UISettings = (): JSX.Element => {
const { loading, config } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
// Initial state
const [formData, setFormData] = useState<UISettingsForm>(uiSettingsTemplate);
// Get config
useEffect(() => {
setFormData({
...config,
});
}, [loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await updateConfig(formData);
// Update local page title
document.title = formData.customTitle;
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<UISettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
{/* === OTHER OPTIONS === */}
<SettingsHeadline text="Miscellaneous" />
{/* PAGE TITLE */}
<InputGroup>
<label htmlFor="customTitle">Custom page title</label>
<input
type="text"
id="customTitle"
name="customTitle"
placeholder="Flame"
value={formData.customTitle}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* === SEARCH OPTIONS === */}
<SettingsHeadline text="Search" />
{/* HIDE SEARCHBAR */}
<InputGroup>
<label htmlFor="hideSearch">Hide search bar</label>
<select
id="hideSearch"
name="hideSearch"
value={formData.hideSearch ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* AUTOFOCUS SEARCHBAR */}
<InputGroup>
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
<select
id="disableAutofocus"
name="disableAutofocus"
value={formData.disableAutofocus ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* === HEADER OPTIONS === */}
<SettingsHeadline text="Header" />
{/* HIDE HEADER */}
<InputGroup>
<label htmlFor="hideHeader">
Hide headline (greetings and weather)
</label>
<select
id="hideHeader"
name="hideHeader"
value={formData.hideHeader ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE DATE */}
<InputGroup>
<label htmlFor="hideDate">Hide date</label>
<select
id="hideDate"
name="hideDate"
value={formData.hideDate ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE TIME */}
<InputGroup>
<label htmlFor="showTime">Hide time</label>
<select
id="showTime"
name="showTime"
value={formData.showTime ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={0}>True</option>
<option value={1}>False</option>
</select>
</InputGroup>
{/* DATE FORMAT */}
<InputGroup>
<label htmlFor="useAmericanDate">Date formatting</label>
<select
id="useAmericanDate"
name="useAmericanDate"
value={formData.useAmericanDate ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Friday, October 22 2021</option>
<option value={0}>Friday, 22 October 2021</option>
</select>
</InputGroup>
{/* CUSTOM GREETINGS */}
<InputGroup>
<label htmlFor="greetingsSchema">Custom greetings</label>
<input
type="text"
id="greetingsSchema"
name="greetingsSchema"
placeholder="Good day;Hi;Bye!"
value={formData.greetingsSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Greetings must be separated with semicolon. All 4 messages must be
filled, even if they are the same
</span>
</InputGroup>
{/* CUSTOM DAYS */}
<InputGroup>
<label htmlFor="daySchema">Custom weekday names</label>
<input
type="text"
id="daySchema"
name="daySchema"
placeholder="Sunday;Monday;Tuesday"
value={formData.daySchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* CUSTOM MONTHS */}
<InputGroup>
<label htmlFor="monthSchema">Custom month names</label>
<input
type="text"
id="monthSchema"
name="monthSchema"
placeholder="January;February;March"
value={formData.monthSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* === SECTIONS OPTIONS === */}
<SettingsHeadline text="Sections" />
{/* HIDE APPS */}
<InputGroup>
<label htmlFor="hideApps">Hide applications</label>
<select
id="hideApps"
name="hideApps"
value={formData.hideApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE BOOKMARK CATEGORIES */}
<InputGroup>
<label htmlFor="hideCategories">Hide bookmarks</label>
<select
id="hideCategories"
name="hideCategories"
value={formData.hideCategories ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
);
};

View file

@ -2,34 +2,29 @@ import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
import axios from 'axios';
// Redux
import { connect } from 'react-redux';
import { createNotification, updateConfig } from '../../../store/actions';
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
// Typescript
import {
ApiResponse,
Config,
GlobalState,
NewNotification,
Weather,
WeatherForm,
} from '../../../interfaces';
import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
import { InputGroup, Button, SettingsHeadline } from '../../UI';
// Utils
import { inputHandler, weatherSettingsTemplate } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: WeatherForm) => void;
loading: boolean;
config: Config;
}
export const WeatherSettings = (): JSX.Element => {
const { loading, config } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { createNotification, updateConfig } = bindActionCreators(
actionCreators,
dispatch
);
const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Initial state
const [formData, setFormData] = useState<WeatherForm>(
weatherSettingsTemplate
@ -38,9 +33,9 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Get config
useEffect(() => {
setFormData({
...props.config,
...config,
});
}, [props.loading]);
}, [loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
@ -48,26 +43,26 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Check for api key input
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
props.createNotification({
createNotification({
title: 'Warning',
message: 'API key is missing. Weather Module will NOT work',
});
}
// Save settings
await props.updateConfig(formData);
await updateConfig(formData);
// Update weather
axios
.get<ApiResponse<Weather>>('/api/weather/update')
.then(() => {
props.createNotification({
createNotification({
title: 'Success',
message: 'Weather updated',
});
})
.catch((err) => {
props.createNotification({
createNotification({
title: 'Error',
message: err.response.data.error,
});
@ -87,8 +82,23 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
});
};
// Get user location
const getLocation = () => {
window.navigator.geolocation.getCurrentPosition(
({ coords: { latitude, longitude } }) => {
setFormData({
...formData,
lat: latitude,
long: longitude,
});
}
);
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<SettingsHeadline text="API" />
{/* API KEY */}
<InputGroup>
<label htmlFor="WEATHER_API_KEY">API key</label>
<input
@ -108,8 +118,11 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
. Key is required for weather module to work.
</span>
</InputGroup>
<SettingsHeadline text="Location" />
{/* LAT */}
<InputGroup>
<label htmlFor="lat">Location latitude</label>
<label htmlFor="lat">Latitude</label>
<input
type="number"
id="lat"
@ -120,19 +133,14 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
step="any"
lang="en-150"
/>
<span>
You can use
<a
href="https://www.latlong.net/convert-address-to-lat-long.html"
target="blank"
>
{' '}
latlong.net
</a>
<span onClick={getLocation}>
<a href="#">Click to get current location</a>
</span>
</InputGroup>
{/* LONG */}
<InputGroup>
<label htmlFor="long">Location longitude</label>
<label htmlFor="long">Longitude</label>
<input
type="number"
id="long"
@ -144,6 +152,9 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
lang="en-150"
/>
</InputGroup>
<SettingsHeadline text="Other" />
{/* TEMPERATURE */}
<InputGroup>
<label htmlFor="isCelsius">Temperature unit</label>
<select
@ -156,18 +167,22 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
<option value={0}>Fahrenheit</option>
</select>
</InputGroup>
{/* WEATHER DATA */}
<InputGroup>
<label htmlFor="weatherData">Additional weather data</label>
<select
id="weatherData"
name="weatherData"
value={formData.weatherData}
onChange={(e) => inputChangeHandler(e)}
>
<option value="cloud">Cloud coverage</option>
<option value="humidity">Humidity</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading,
config: state.config.config,
};
};
export default connect(mapStateToProps, { createNotification, updateConfig })(
WeatherSettings
);

View file

@ -2,27 +2,38 @@
"routes": [
{
"name": "Theme",
"dest": "/settings"
"dest": "/settings",
"authRequired": false
},
{
"name": "General",
"dest": "/settings/general",
"authRequired": true
},
{
"name": "Interface",
"dest": "/settings/interface",
"authRequired": true
},
{
"name": "Weather",
"dest": "/settings/weather"
"dest": "/settings/weather",
"authRequired": true
},
{
"name": "Search",
"dest": "/settings/search"
},
{
"name": "Other",
"dest": "/settings/other"
"name": "Docker",
"dest": "/settings/docker",
"authRequired": true
},
{
"name": "CSS",
"dest": "/settings/css"
"dest": "/settings/css",
"authRequired": true
},
{
"name": "App",
"dest": "/settings/app"
"dest": "/settings/app",
"authRequired": false
}
]
}

View file

@ -1,31 +0,0 @@
import { Theme } from '../../interfaces/Theme';
import classes from './ThemePreview.module.css';
interface ComponentProps {
theme: Theme;
applyTheme: Function;
}
const ThemePreview = (props: ComponentProps): JSX.Element => {
return (
<div className={classes.ThemePreview} onClick={() => props.applyTheme(props.theme.name)}>
<div className={classes.ColorsPreview}>
<div
className={classes.ColorPreview}
style={{ backgroundColor: props.theme.colors.background }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: props.theme.colors.primary }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: props.theme.colors.accent }}
></div>
</div>
<p>{props.theme.name}</p>
</div>
)
}
export default ThemePreview;

View file

@ -1,37 +0,0 @@
import { Fragment } from 'react';
import { connect } from 'react-redux';
import classes from './Themer.module.css';
import { themes } from './themes.json';
import { Theme } from '../../interfaces/Theme';
import ThemePreview from './ThemePreview';
import { setTheme } from '../../store/actions';
interface ComponentProps {
setTheme: Function;
}
const Themer = (props: ComponentProps): JSX.Element => {
return (
<Fragment>
<div>
<div className={classes.ThemerGrid}>
{themes.map((theme: Theme, idx: number): JSX.Element => (
<ThemePreview
key={idx}
theme={theme}
applyTheme={props.setTheme}
/>
))}
</div>
</div>
</Fragment>
)
}
export default connect(null, { setTheme })(Themer);

View file

@ -2,55 +2,45 @@ import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import classes from './ActionButton.module.css';
import Icon from '../../Icons/Icon/Icon';
import { Icon } from '../..';
interface ComponentProps {
interface Props {
name: string;
icon: string;
link?: string;
handler?: () => void;
}
const ActionButton = (props: ComponentProps): JSX.Element => {
export const ActionButton = (props: Props): JSX.Element => {
const body = (
<Fragment>
<div className={classes.ActionButtonIcon}>
<Icon icon={props.icon} />
</div>
<div className={classes.ActionButtonName}>
{props.name}
</div>
<div className={classes.ActionButtonName}>{props.name}</div>
</Fragment>
);
if (props.link) {
return (
<Link
to={props.link}
tabIndex={0}>
<Link to={props.link} tabIndex={0}>
{body}
</Link>
)
);
} else if (props.handler) {
return (
<div
className={classes.ActionButton}
onClick={props.handler}
onKeyPress={(e) => {
if (e.key === 'Enter' && props.handler) props.handler()
if (e.key === 'Enter' && props.handler) props.handler();
}}
tabIndex={0}
>{body}
</div>
)
} else {
return (
<div
className={classes.ActionButton}>
>
{body}
</div>
)
);
} else {
return <div className={classes.ActionButton}>{body}</div>;
}
}
export default ActionButton;
};

View file

@ -1,21 +1,17 @@
import { ReactNode } from 'react';
import classes from './Button.module.css';
interface ComponentProps {
children: string;
interface Props {
children: ReactNode;
click?: any;
}
const Button = (props: ComponentProps): JSX.Element => {
const {
children,
click
} = props;
export const Button = (props: Props): JSX.Element => {
const { children, click } = props;
return (
<button className={classes.Button} onClick={click ? click : () => {}} >
<button className={classes.Button} onClick={click ? click : () => {}}>
{children}
</button>
)
}
export default Button;
);
};

View file

@ -23,7 +23,7 @@
.InputGroup span {
font-size: 12px;
color: var(--color-primary)
color: var(--color-primary);
}
.InputGroup span a {
@ -37,4 +37,14 @@
.InputGroup textarea {
resize: none;
height: 50vh;
}
}
.InputGroup input[type='color'] {
margin: 0;
padding: 0;
background-color: transparent;
}
.InputGroup input[type='color']:hover {
cursor: pointer;
}

View file

@ -1,15 +1,10 @@
import { ReactNode } from 'react';
import classes from './InputGroup.module.css';
interface ComponentProps {
children: JSX.Element | JSX.Element[];
interface Props {
children: ReactNode;
}
const InputGroup = (props: ComponentProps): JSX.Element => {
return (
<div className={classes.InputGroup}>
{props.children}
</div>
)
}
export default InputGroup;
export const InputGroup = (props: Props): JSX.Element => {
return <div className={classes.InputGroup}>{props.children}</div>;
};

View file

@ -1,31 +1,27 @@
import { SyntheticEvent } from 'react';
import { ReactNode, SyntheticEvent } from 'react';
import classes from './ModalForm.module.css';
import Icon from '../../Icons/Icon/Icon';
import { Icon } from '../..';
interface ComponentProps {
children: JSX.Element | JSX.Element[];
children: ReactNode;
modalHandler?: () => void;
formHandler: (e: SyntheticEvent<HTMLFormElement>) => void;
}
const ModalForm = (props: ComponentProps): JSX.Element => {
export const ModalForm = (props: ComponentProps): JSX.Element => {
const _modalHandler = (): void => {
if (props.modalHandler) {
props.modalHandler();
}
}
};
return (
<div className={classes.ModalForm}>
<div className={classes.ModalFormIcon} onClick={_modalHandler}>
<Icon icon='mdiClose' />
<Icon icon="mdiClose" />
</div>
<form onSubmit={(e) => props.formHandler(e)}>
{props.children}
</form>
<form onSubmit={(e) => props.formHandler(e)}>{props.children}</form>
</div>
)
}
export default ModalForm;
);
};

View file

@ -1,18 +1,18 @@
import { Fragment } from 'react';
import { Fragment, ReactNode } from 'react';
import classes from './Headline.module.css';
interface ComponentProps {
interface Props {
title: string;
subtitle?: string | JSX.Element;
subtitle?: ReactNode;
}
const Headline = (props: ComponentProps): JSX.Element => {
export const Headline = (props: Props): JSX.Element => {
return (
<Fragment>
<h1 className={classes.HeadlineTitle}>{props.title}</h1>
{props.subtitle && <p className={classes.HeadlineSubtitle}>{props.subtitle}</p>}
{props.subtitle && (
<p className={classes.HeadlineSubtitle}>{props.subtitle}</p>
)}
</Fragment>
)
}
export default Headline;
);
};

View file

@ -2,17 +2,15 @@ import { Link } from 'react-router-dom';
import classes from './SectionHeadline.module.css';
interface ComponentProps {
interface Props {
title: string;
link: string
link: string;
}
const SectionHeadline = (props: ComponentProps): JSX.Element => {
export const SectionHeadline = (props: Props): JSX.Element => {
return (
<Link to={props.link}>
<h2 className={classes.SectionHeadline}>{props.title}</h2>
</Link>
)
}
export default SectionHeadline;
);
};

View file

@ -1,11 +1,9 @@
const classes = require('./SettingsHeadline.module.css');
import classes from './SettingsHeadline.module.css';
interface Props {
text: string;
}
const SettingsHeadline = (props: Props): JSX.Element => {
export const SettingsHeadline = (props: Props): JSX.Element => {
return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
};
export default SettingsHeadline;

View file

@ -0,0 +1,11 @@
.ActionIcons {
display: flex;
}
.ActionIcons svg {
width: 20px;
}
.ActionIcons svg:hover {
cursor: pointer;
}

View file

@ -0,0 +1,10 @@
import { ReactNode } from 'react';
import styles from './ActionIcons.module.css';
interface Props {
children: ReactNode;
}
export const ActionIcons = ({ children }: Props): JSX.Element => {
return <span className={styles.ActionIcons}>{children}</span>;
};

View file

@ -1,6 +1,4 @@
.Icon {
color: var(--color-primary);
/* for settings */
/* color: var(--color-background); */
width: 90%;
}
}

View file

@ -2,12 +2,12 @@ import classes from './Icon.module.css';
import { Icon as MDIcon } from '@mdi/react';
interface ComponentProps {
interface Props {
icon: string;
color?: string;
}
const Icon = (props: ComponentProps): JSX.Element => {
export const Icon = (props: Props): JSX.Element => {
const MDIcons = require('@mdi/js');
let iconPath = MDIcons[props.icon];
@ -22,7 +22,5 @@ const Icon = (props: ComponentProps): JSX.Element => {
path={iconPath}
color={props.color ? props.color : 'var(--color-primary)'}
/>
)
}
export default Icon;
);
};

View file

@ -1,39 +1,32 @@
import { useEffect } from 'react';
import { connect } from 'react-redux';
import { useSelector } from 'react-redux';
import { Skycons } from 'skycons-ts';
import { GlobalState, Theme } from '../../../../interfaces';
import { State } from '../../../../store/reducers';
import { IconMapping, TimeOfDay } from './IconMapping';
interface ComponentProps {
theme: Theme;
interface Props {
weatherStatusCode: number;
isDay: number;
}
const WeatherIcon = (props: ComponentProps): JSX.Element => {
export const WeatherIcon = (props: Props): JSX.Element => {
const { activeTheme } = useSelector((state: State) => state.theme);
const icon = props.isDay
? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)
: new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.night);
useEffect(() => {
const delay = setTimeout(() => {
const skycons = new Skycons({'color': props.theme.colors.accent});
const skycons = new Skycons({ color: activeTheme.colors.accent });
skycons.add(`weather-icon`, icon);
skycons.play();
}, 1);
return () => {
clearTimeout(delay);
}
}, [props.weatherStatusCode, icon, props.theme.colors.accent]);
};
}, [props.weatherStatusCode, icon, activeTheme.colors.accent]);
return <canvas id={`weather-icon`} width='50' height='50'></canvas>
}
const mapStateToProps = (state: GlobalState) => {
return {
theme: state.theme.theme
}
}
export default connect(mapStateToProps)(WeatherIcon);
return <canvas id={`weather-icon`} width="50" height="50"></canvas>;
};

View file

@ -1,13 +1,10 @@
import { ReactNode } from 'react';
import classes from './Layout.module.css';
interface ComponentProps {
children: JSX.Element | JSX.Element[];
children: ReactNode;
}
export const Container = (props: ComponentProps): JSX.Element => {
return (
<div className={classes.Container}>
{props.children}
</div>
)
}
return <div className={classes.Container}>{props.children}</div>;
};

View file

@ -1,28 +1,37 @@
import { MouseEvent, useRef } from 'react';
import { MouseEvent, ReactNode, useRef } from 'react';
import classes from './Modal.module.css';
interface ComponentProps {
interface Props {
isOpen: boolean;
setIsOpen: Function;
children: JSX.Element;
children: ReactNode;
cb?: Function;
}
const Modal = (props: ComponentProps): JSX.Element => {
export const Modal = ({
isOpen,
setIsOpen,
children,
cb,
}: Props): JSX.Element => {
const modalRef = useRef(null);
const modalClasses = [classes.Modal, props.isOpen ? classes.ModalOpen : classes.ModalClose].join(' ');
const modalClasses = [
classes.Modal,
isOpen ? classes.ModalOpen : classes.ModalClose,
].join(' ');
const clickHandler = (e: MouseEvent) => {
if (e.target === modalRef.current) {
props.setIsOpen(false);
setIsOpen(false);
if (cb) cb();
}
}
};
return (
<div className={modalClasses} onClick={clickHandler} ref={modalRef}>
{props.children}
{children}
</div>
)
}
export default Modal;
);
};

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