Compare commits

...

260 commits
v1.1 ... master

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

* chore(): kubernetes integration

* chore(skaffold): refine shokohsc profile

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

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

This reverts commit 5111c7ad79.
2021-08-17 10:32:15 +02:00
pawelmalak
c1b61f9cd9
Merge pull request #79 from pawelmalak/icon-fix
Bookmark icon fixes
2021-08-09 15:38:41 +02:00
unknown
78a018f686 Bookmark icon fixes 2021-08-09 15:31:20 +02:00
pawelmalak
36c9b7648a
Merge pull request #76 from pawelmalak/feature
Added support for custom SVG Icons. Added new search queries
2021-08-09 13:01:46 +02:00
unknown
5c60c7c156 Pushed version 1.6.3. Added Deezer and Tidal to search queries 2021-08-09 12:54:07 +02:00
unknown
683c948f6c Added cli tool for adding new search engines/providers 2021-08-06 16:16:13 +02:00
unknown
1699146f79 Added support for custom SVG icons 2021-08-06 15:15:54 +02:00
unknown
a01661d0d5 Pushed version 1.6.2. Small formatting fixes 2021-08-06 10:36:05 +02:00
pawelmalak
1962af01e6
Merge pull request #72 from pruizlezcano/docker-api
Added Docker API
2021-08-06 10:28:58 +02:00
Pablo Ruiz
39349dded1 fix optional docker.sock mount 2021-08-05 08:56:02 +02:00
Pablo Ruiz
b53509aa69 docker api 2021-08-04 22:19:35 +02:00
pawelmalak
b5ba9856ed
Merge pull request #70 from itsbhanusharma/patch-1
Use correct changelog link
2021-08-03 10:23:37 +02:00
unknown
b94df53267 github directory name changed 2021-08-03 10:22:55 +02:00
Bhanu
4b42f991f8
Use correct changelog link
current link in release 1.6.1 points to an incorrect url that returns 404
2021-08-03 11:51:54 +05:30
pawelmalak
2ceff6828a
Merge pull request #69 from pawelmalak/fixes
Fixed custom icons not updating and added custom icons for bookmarks
2021-07-28 12:56:19 +02:00
unknown
d39eda49de Added changelog. Added curl to Docker image 2021-07-28 12:52:30 +02:00
unknown
a5d6cf04cf Custom icons for bookmarks 2021-07-28 12:36:03 +02:00
unknown
1fbe0746a4 Fixed custom icons not updating 2021-07-28 11:36:48 +02:00
pawelmalak
f93659b661
Merge pull request #66 from pawelmalak/feature
v1.6 Release
2021-07-17 23:29:02 +02:00
unknown
88785aaa32 Fixed bug with custom css not persisting 2021-07-17 23:11:24 +02:00
unknown
4143ae8198 Added support for Steam URLs. Changed default prefix setting options to be dynamically rendered 2021-07-16 11:55:26 +02:00
pawelmalak
f1c48e8a15
Merge pull request #61 from jjack/master
Adding a default search provider option, with DuckDuckGo as the default
2021-07-16 11:23:58 +02:00
pawelmalak
6445a5009a
Merge pull request #56 from strig/patch-1
Add docker-compose instructions and link in readme
2021-07-15 11:57:18 +02:00
Jeremy Jack
112a35c08f Adding a default search provider option, with DuckDuckGo as the default 2021-07-05 23:04:03 -05:00
Neal Striegler-Pettersson
7970ac3031
Add docker-compose instructions and link in readme 2021-06-27 19:33:27 -04:00
unknown
c03f302fa6 Added option to open links in the same tab for apps/bookamrs/search separately 2021-06-25 11:24:29 +02:00
unknown
0c3a27febd Added warning about unsupported prefix. Added support for Spotify search. Edited README file 2021-06-25 10:42:44 +02:00
pawelmalak
aec00982ba
Merge pull request #49 from pawelmalak/feature
v1.5 Release
2021-06-24 13:32:26 +02:00
unknown
8026533a06 Added search bar 2021-06-24 12:53:45 +02:00
unknown
550e1e155b Added option to hide apps and categories from home screen 2021-06-24 10:54:48 +02:00
unknown
12974ab01b Upload custom icon on client 2021-06-23 15:27:46 +02:00
unknown
6c067bee31 Option to open links in the same tab. Api upload icon. Render image icon instead of MDI. Dockerfile client dependencies fix. 2021-06-23 14:15:14 +02:00
pawelmalak
db4a10171e
Merge pull request #46 from rgriffogoes/master
Adding npm install on client code
2021-06-23 11:09:17 +02:00
Rafael Griffo Goes
472cfd6610 Adding npm install on client code 2021-06-22 22:54:24 -04:00
unknown
e3ed429da1 Imporved logger 2021-06-22 14:49:00 +02:00
unknown
5ae4d6e7c4 Read/write css file from app settings. Changed order of operations at app startup. Added nano to Dockerfile 2021-06-22 13:07:32 +02:00
unknown
4c3255107c Updated README.md with new screenshots, new installation guide and weather module instructions 2021-06-21 13:59:17 +02:00
pawelmalak
41a3f5dae3
Merge pull request #42 from pawelmalak/feature
v1.4 Release
2021-06-18 15:12:44 +02:00
pawelmalak
4ca3b509cf
Merge branch 'master' into feature 2021-06-18 15:12:36 +02:00
unknown
28680bec1a Fixed bug with decimal input values in Safari browser 2021-06-18 14:12:17 +02:00
unknown
ae3141e37b Sorting and custom ordering for categories 2021-06-18 13:42:55 +02:00
unknown
5b900872af Apps reordering with drag-and-drop functionality 2021-06-18 12:09:59 +02:00
unknown
754dc3a7b9 Sorting settings. Sort apps on change/add/update 2021-06-18 10:38:05 +02:00
unknown
8974fb3b49 Preparation for custom sorting 2021-06-17 10:56:27 +02:00
unknown
ce173f2c42 Apps reordering. Sorting apps while adding them 2021-06-15 16:02:57 +02:00
unknown
9a1ec76ffd Case-insensitive sorting. App version checking 2021-06-15 12:36:23 +02:00
pawelmalak
a9be4df157
Create client/.env 2021-06-15 12:05:46 +02:00
unknown
e884c84aa8 Fixes for apps and bookmarks tabs 2021-06-14 12:19:53 +02:00
unknown
ad5e7646c1 Fixed infinite data fetching bug on homescreen. Docker files 2021-06-14 12:13:38 +02:00
pawelmalak
ff1d11f512
Merge pull request #35 from pawelmalak/feature
v1.3 Release
2021-06-14 00:03:32 +02:00
unknown
5e7cb72b82 Reworked OtherSettings to work with global config state. Fixed bug with certain settings not being synchronized 2021-06-13 23:21:35 +02:00
unknown
f137498e7e Added auto-refresh for greeting and date. Fixed multiple React warnings 2021-06-13 01:06:42 +02:00
unknown
d257fbf9a3 Created config global state. Reworked WeatherSettings and WeatherWidget to use new config state. 2021-06-13 00:16:57 +02:00
unknown
a5504e6e80 Added url parser to support wider range of addresses 2021-06-11 15:33:06 +02:00
pawelmalak
5968663be4
Merge pull request #25 from pawelmalak/reverse-proxy-support
Fixed bug related to websocket protocol which was making app unusable…
2021-06-11 00:10:08 +02:00
unknown
66cc59c48e Fixed bug related to websocket protocol which was making app unusable with reverse proxy and https 2021-06-11 00:09:25 +02:00
unknown
f5f735372a Added License file 2021-06-10 13:44:03 +02:00
pawelmalak
91ab1c5ae4
Merge pull request #21 from pawelmalak/feature
Release v1.2
2021-06-10 13:21:51 +02:00
unknown
78de8752c6 Fixed bug with overwriting opened tabs. Added proxy for websocket 2021-06-10 13:05:55 +02:00
unknown
936da301b8 Clear weather data job. Fixed bug with displaying bookmark icons on mobile devices 2021-06-10 01:51:59 +02:00
unknown
80c807bfba Fixed typo in Dockerfile. Added some checks to weather module settings 2021-06-09 22:26:39 +02:00
unknown
4583ca00e9 Added ability to set icons on bookmarks. Added hover indicator for apps 2021-06-09 12:45:55 +02:00
unknown
8b87ad92f1 Added option to pin new apps/categories by default 2021-06-09 10:58:45 +02:00
pawelmalak
43e110d378
Merge pull request #10 from pawelmalak/dependabot/npm_and_yarn/ws-7.4.6
Bump ws from 7.4.5 to 7.4.6
2021-06-09 01:24:22 +02:00
dependabot[bot]
a8217e2632
Bump ws from 7.4.5 to 7.4.6
Bumps [ws](https://github.com/websockets/ws) from 7.4.5 to 7.4.6.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/7.4.5...7.4.6)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-08 23:22:59 +00:00
330 changed files with 40122 additions and 14300 deletions

10
.dev/DEV_GUIDELINES.md Normal file
View file

@ -0,0 +1,10 @@
## Adding new config key
1. Edit utils/init/initialConfig.json
2. Edit client/src/interfaces/Config.ts
3. Edit client/src/utility/templateObjects/configTemplate.ts
If config value will be used in a form:
4. Edit client/src/interfaces/Forms.ts
5. Edit client/src/utility/templateObjects/settingsTemplate.ts

166
.dev/bookmarks_importer.py Executable file
View file

@ -0,0 +1,166 @@
import sqlite3
from bs4 import BeautifulSoup
from PIL import Image, UnidentifiedImageError
from io import BytesIO
import re
import base64
from datetime import datetime, timezone
import os
import argparse
"""
Imports html bookmarks file into Flame.
Tested only on Firefox html exports so far.
Usage:
python3 bookmarks_importer.py --bookmarks <path to bookmarks file> --data <path to flame data dir>
"""
parser = argparse.ArgumentParser()
parser.add_argument('--bookmarks', type=str, required=True)
parser.add_argument('--data', type=str, required=True)
args = parser.parse_args()
bookmarks_path = args.bookmarks
data_path = args.data
created = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + datetime.now().astimezone().strftime(" %z")
updated = created
if data_path[-1] != '/':
data_path = data_path + '/'
def Base64toPNG(codec, name):
"""
Convert base64 encoded image to png file
Reference: https://github.com/python-pillow/Pillow/issues/3400#issuecomment-428104239
Parameters:
codec (str): icon in html bookmark format.e.g. 'data:image/png;base64,<image encoding>'
name (str): name for export file
Returns:
icon_name(str): name of png output E.g. 1636473849374--mybookmark.png
None: if image not produced successfully
"""
try:
unix_t = str(int(datetime.now(tz=timezone.utc).timestamp() * 1000))
icon_name = unix_t + '--' + re.sub(r'\W+', '', name).lower() + '.png'
image_path = data_path + 'uploads/' + icon_name
if os.path.exists(image_path):
return image_path
base64_data = re.sub('^data:image/.+;base64,', '', codec)
byte_data = base64.b64decode(base64_data)
image_data = BytesIO(byte_data)
img = Image.open(image_data)
img.save(image_path, "PNG")
return icon_name
except UnidentifiedImageError:
return None
def FlameBookmarkParser(bookmarks_path):
"""
Parses HTML bookmarks file
Reference: https://stackoverflow.com/questions/68621107/extracting-bookmarks-and-folder-hierarchy-from-google-chrome-with-beautifulsoup
Parameters:
bookmarks_path (str): path to bookmarks.html
Returns:
None
"""
soup = BeautifulSoup()
with open(bookmarks_path) as f:
soup = BeautifulSoup(f.read(), 'lxml')
dt = soup.find_all('dt')
folder_name =''
for i in dt:
n = i.find_next()
if n.name == 'h3':
folder_name = n.text
continue
else:
url = n.get("href")
website_name = n.text
icon = n.get("icon")
if icon != None:
icon_name = Base64toPNG(icon, website_name)
cat_id = AddFlameCategory(folder_name)
AddFlameBookmark(website_name, url, cat_id, icon_name)
def AddFlameCategory(cat_name):
"""
Parses HTML bookmarks file
Parameters:
cat_name (str): category name
Returns:
cat_id (int): primary key id of cat_name
"""
con = sqlite3.connect(data_path + 'db.sqlite')
cur = con.cursor()
count_sql = ("SELECT count(*) FROM categories WHERE name = ?;")
cur.execute(count_sql, [cat_name])
count = int(cur.fetchall()[0][0])
if count > 0:
getid_sql = ("SELECT id FROM categories WHERE name = ?;")
cur.execute(getid_sql, [cat_name])
cat_id = int(cur.fetchall()[0][0])
return cat_id
is_pinned = 1
insert_sql = "INSERT OR IGNORE INTO categories(name, isPinned, createdAt, updatedAt) VALUES (?, ?, ?, ?);"
cur.execute(insert_sql, (cat_name, is_pinned, created, updated))
con.commit()
getid_sql = ("SELECT id FROM categories WHERE name = ?;")
cur.execute(getid_sql, [cat_name])
cat_id = int(cur.fetchall()[0][0])
return cat_id
def AddFlameBookmark(website_name, url, cat_id, icon_name):
con = sqlite3.connect(data_path + 'db.sqlite')
cur = con.cursor()
if icon_name == None:
insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?);"
cur.execute(insert_sql, (website_name, url, cat_id, created, updated))
con.commit()
else:
insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, icon, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?);"
cur.execute(insert_sql, (website_name, url, cat_id, icon_name, created, updated))
con.commit()
if __name__ == "__main__":
FlameBookmarkParser(bookmarks_path)

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 .

9
.dev/getMdi.js Normal file
View file

@ -0,0 +1,9 @@
// Script to get all icon names from materialdesignicons.com
const getMdi = () => {
const icons = document.querySelectorAll('#icons div span');
const names = [...icons].map((icon) => icon.textContent.replace('mdi-', ''));
const output = names.map((name) => ({ name }));
output.pop();
const json = JSON.stringify(output);
console.log(json);
};

30
.docker/Dockerfile Normal file
View file

@ -0,0 +1,30 @@
FROM node:16 as builder
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
RUN mkdir -p ./public ./data \
&& cd ./client \
&& npm install --production \
&& npm run build \
&& cd .. \
&& mv ./client/build/* ./public \
&& rm -rf ./client
FROM node:16-alpine
COPY --from=builder /app /app
WORKDIR /app
EXPOSE 5005
ENV NODE_ENV=production
ENV PASSWORD=flame_password
CMD ["sh", "-c", "chown -R node /app/data && node server.js"]

26
.docker/Dockerfile.dev Normal file
View file

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

View file

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

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,2 +1,6 @@
node_modules
github
.github
public
k8s
skaffold.yaml
data

5
.env Normal file
View file

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

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

BIN
.github/themes.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

7
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules/
data/
.env
node_modules
data
public
!client/public

2
.prettierignore Normal file
View file

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

8
.prettierrc Normal file
View file

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

176
CHANGELOG.md Normal file
View file

@ -0,0 +1,176 @@
### v2.3.1 (2023-07-23)
- Fixed bug where "Open search results in the same tab" setting was not respected if "Local search" was set as primary search provider ([#270](https://github.com/pawelmalak/flame/issues/270))
- Fixed bug where search bar had rounded input field on iOS ([#394](https://github.com/pawelmalak/flame/issues/394))
- Updated link to Material Design Icons reference page ([#414](https://github.com/pawelmalak/flame/issues/414))
- Fixed bug where color inputs in theme creator/editor were too small ([#429](https://github.com/pawelmalak/flame/issues/429))
- Changed input labels in settings for more consistent naming ([#430](https://github.com/pawelmalak/flame/issues/430))
### v2.3.0 (2022-03-25)
- Added custom theme editor ([#246](https://github.com/pawelmalak/flame/issues/246))
- Added option to set secondary search provider ([#295](https://github.com/pawelmalak/flame/issues/295))
- Fixed bug where pressing Enter with empty search bar would redirect to search results ([#325](https://github.com/pawelmalak/flame/issues/325))
- Fixed bug where user could create empty app or bookmark which was causing page to go blank ([#332](https://github.com/pawelmalak/flame/issues/332))
- Added new theme: Mint
### v2.2.2 (2022-03-21)
- Added option to get user location directly from the app ([#287](https://github.com/pawelmalak/flame/issues/287))
- Fixed bug with local search not working when using prefix ([#289](https://github.com/pawelmalak/flame/issues/289))
- Fixed bug with app description not updating when using custom icon ([#310](https://github.com/pawelmalak/flame/issues/310))
- Changed permissions to some files and directories created by Flame
- Changed some of the settings tabs
### v2.2.1 (2022-01-08)
- Local search will now include app descriptions ([#266](https://github.com/pawelmalak/flame/issues/266))
- Fixed bug with unsupported characters in local search [#279](https://github.com/pawelmalak/flame/issues/279))
- Background tasks optimization ([#283](https://github.com/pawelmalak/flame/issues/283))
### v2.2.0 (2021-12-17)
- Added option to set custom description for apps ([#201](https://github.com/pawelmalak/flame/issues/201))
- Fixed fatal error while deploying Flame to cluster ([#242](https://github.com/pawelmalak/flame/issues/242))
### v2.1.1 (2021-12-02)
- Added support for Docker secrets ([#189](https://github.com/pawelmalak/flame/issues/189))
- Changed some messages and buttons to make it easier to open bookmarks editor ([#239](https://github.com/pawelmalak/flame/issues/239))
### v2.1.0 (2021-11-26)
- Added option to set custom order for bookmarks ([#43](https://github.com/pawelmalak/flame/issues/43)) and ([#187](https://github.com/pawelmalak/flame/issues/187))
- Added support for .ico files for custom icons ([#209](https://github.com/pawelmalak/flame/issues/209))
- Empty apps and categories sections will now be hidden from guests ([#210](https://github.com/pawelmalak/flame/issues/210))
- Fixed bug with fahrenheit degrees being displayed as float ([#221](https://github.com/pawelmalak/flame/issues/221))
- Fixed bug with alphabetical order not working for bookmarks until the page was refreshed ([#224](https://github.com/pawelmalak/flame/issues/224))
- Added option to change visibilty of apps, categories and bookmarks directly from table view
- Password input will now autofocus when visiting /settings/app
### v2.0.1 (2021-11-19)
- Added option to display humidity in the weather widget ([#136](https://github.com/pawelmalak/flame/issues/136))
- Added option to set default theme for all new users ([#165](https://github.com/pawelmalak/flame/issues/165))
- Added option to hide header greetings and date separately ([#200](https://github.com/pawelmalak/flame/issues/200))
- Fixed bug with broken basic auth ([#202](https://github.com/pawelmalak/flame/issues/202))
- Fixed bug with parsing visibility value for apps and bookmarks when custom icon was used ([#203](https://github.com/pawelmalak/flame/issues/203))
- Fixed bug with custom icons not working with apps when "pin by default" was disabled
### v2.0.0 (2021-11-15)
- Added authentication system:
- Only logged in user can access settings ([#33](https://github.com/pawelmalak/flame/issues/33))
- User can set which apps, categories and bookmarks should be available for guest users ([#45](https://github.com/pawelmalak/flame/issues/45))
- Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about this feature
- Docker images will now be versioned ([#110](https://github.com/pawelmalak/flame/issues/110))
- Icons can now be set via URL ([#138](https://github.com/pawelmalak/flame/issues/138))
- Added current time to the header ([#157](https://github.com/pawelmalak/flame/issues/157))
- Fixed bug where typing certain characters in the search bar would result in a blank page ([#158](https://github.com/pawelmalak/flame/issues/158))
- Fixed bug with MDI icon name not being properly parsed if there was leading or trailing whitespace ([#164](https://github.com/pawelmalak/flame/issues/164))
- Added new shortcut to clear search bar and focus on it ([#170](https://github.com/pawelmalak/flame/issues/170))
- Added Wikipedia to search queries
- Updated project wiki
- Lots of changes and refactors under the hood to make future development easier
### v1.7.4 (2021-11-08)
- Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103))
- Fallback to web search if local search has zero results ([#129](https://github.com/pawelmalak/flame/issues/129))
- Added iOS "Add to homescreen" icon ([#131](https://github.com/pawelmalak/flame/issues/131))
- Added experimental script to import bookmarks ([#141](https://github.com/pawelmalak/flame/issues/141))
- Added 3 new themes
### v1.7.3 (2021-10-28)
- Fixed bug with custom CSS not updating
### v1.7.2 (2021-10-28)
- Pressing Enter while search bar is focused will now redirect to first result of local search ([#121](https://github.com/pawelmalak/flame/issues/121))
- Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124))
- Fixed bug with Weather API still logging with module being disabled ([#125](https://github.com/pawelmalak/flame/issues/125))
- Added option to disable search bar autofocus ([#127](https://github.com/pawelmalak/flame/issues/127))
### v1.7.1 (2021-10-22)
- Fixed search action not being triggered by Numpad Enter
- Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92))
- Added shortcuts (Esc and double click) to clear search bar ([#100](https://github.com/pawelmalak/flame/issues/100))
- Added Traefik integration ([#102](https://github.com/pawelmalak/flame/issues/102))
- Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118))
- Performance improvements
### v1.7.0 (2021-10-11)
- Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67))
- Users can now add their custom search providers ([#71](https://github.com/pawelmalak/flame/issues/71))
- Fixed bug related to creating new apps/bookmarks with custom icon ([#83](https://github.com/pawelmalak/flame/issues/83))
- URL can now be assigned to notifications. Clicking on "New version is available" popup will now redirect to changelog ([#86](https://github.com/pawelmalak/flame/issues/86))
- Added static fonts ([#94](https://github.com/pawelmalak/flame/issues/94))
- Fixed bug with overriding app icon created with docker labels
### v1.6.9 (2021-10-09)
- Added option for remote docker host ([#97](https://github.com/pawelmalak/flame/issues/97))
### v1.6.8 (2021-10-05)
- Implemented migration system for database
### v1.6.7 (2021-10-04)
- Add multiple labels to Docker Compose ([#90](https://github.com/pawelmalak/flame/issues/90))
- Custom icons via Docker Compose labels ([#91](https://github.com/pawelmalak/flame/issues/91))
### v1.6.6 (2021-09-06)
- Added local search (filter) for apps and bookmarks ([#47](https://github.com/pawelmalak/flame/issues/47))
### v1.6.5 (2021-08-28)
- Added support for more URL schemes ([#74](https://github.com/pawelmalak/flame/issues/74))
### v1.6.4 (2021-08-17)
- Added Kubernetes integration ([#72 continued](https://github.com/pawelmalak/flame/issues/72))
### v1.6.3 (2021-08-09)
- Added support for custom SVG icons ([#73](https://github.com/pawelmalak/flame/issues/73))
- Added Deezer and Tidal to search queries
### v1.6.2 (2021-08-06)
- Fixed changelog link
- Added support for Docker API ([#14](https://github.com/pawelmalak/flame/issues/14))
### v1.6.1 (2021-07-28)
- Added option to upload custom icons for bookmarks ([#52](https://github.com/pawelmalak/flame/issues/52))
- Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
- Added changelog file
### v1.6.0 (2021-07-17)
- Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
- Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
- Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
### v1.5.0 (2021-06-24)
- Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental)
- Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12))
- Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27))
- Added Search bar with support for 3 search engines and 4 services ([#44](https://github.com/pawelmalak/flame/issues/44))
- Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
- Improved Logger
### v1.4.0 (2021-06-18)
- Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
- Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
- Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
- New apps will be placed correctly in the array depending on used sorting settings ([#37](https://github.com/pawelmalak/flame/issues/37))
- Added app version to settings with option to check for updates manually ([#38](https://github.com/pawelmalak/flame/issues/38))
- Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38))
- Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40))
### v1.3.0 (2021-06-14)
- Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
- Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
- Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
- Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
- Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
### v1.2.0 (2021-06-10)
- Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2))
- Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7))
- Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11))
- Added background highlight while hovering over application card ([#15](https://github.com/pawelmalak/flame/issues/15))
- Created CRON job to clear old weather data from the database ([#16](https://github.com/pawelmalak/flame/issues/16))
- Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18))
- Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20))
### v1.1.0 (2021-06-09)
- Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3))
- Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3))
- Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4))
- Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5))
### v1.0.0 (2021-06-08)
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.

View file

@ -1,22 +0,0 @@
FROM node:14-alpine
WORKDIR /app
COPY package*.json .
RUN npm install --only=production
COPY . .
RUN mkdir -p ./public ./data \
&& cd ./client \
&& npm run build \
&& cd .. \
&& mv ./client/build/* ./public \
&& rm -rf ./client
EXPOSE 5005
ENV NODE_ENV=production
CMD ["node", "server.js"]

21
LICENSE.md Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Paweł Malak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

238
README.md
View file

@ -1,11 +1,113 @@
# Flame
![Homescreen screenshot](./github/_home.png)
![Homescreen screenshot](.github/home.png)
## Description
Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui)
## Technology
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary.
## Functionality
- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors
- 📌 Pin your favourite items to the homescreen for quick and easy access
- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
- 🔑 Authentication system to protect your settings, apps and bookmarks
- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS, 15 built-in color themes and custom theme builder
- ☀️ Weather widget with current temperature, cloud coverage and animated weather status
- 🐳 Docker integration to automatically pick and add apps based on their labels
## Installation
### With Docker (recommended)
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
```sh
docker pull pawelmalak/flame
# for ARM architecture (e.g. RaspberryPi)
docker pull pawelmalak/flame:multiarch
# installing specific version
docker pull pawelmalak/flame:2.0.0
```
#### Deployment
```sh
# run container
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password pawelmalak/flame
```
#### Building images
```sh
# build image for amd64 only
docker build -t flame -f .docker/Dockerfile .
# build multiarch image for amd64, armv7 and arm64
# building failed multiple times with 2GB memory usage limit so you might want to increase it
docker buildx build \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-f .docker/Dockerfile.multiarch \
-t flame:multiarch .
```
#### Docker-Compose
```yaml
version: '3.6'
services:
flame:
image: pawelmalak/flame
container_name: flame
volumes:
- /path/to/host/data:/app/data
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration
ports:
- 5005:5005
secrets:
- password # optional but required for (1)
environment:
- PASSWORD=flame_password
- PASSWORD_FILE=/run/secrets/password # optional but required for (1)
restart: unless-stopped
# optional but required for Docker secrets (1)
secrets:
password:
file: /path/to/secrets/password
```
##### Docker Secrets
All environment variables can be overwritten by appending `_FILE` to the variable value. For example, you can use `PASSWORD_FILE` to pass through a docker secret instead of `PASSWORD`. If both `PASSWORD` and `PASSWORD_FILE` are set, the docker secret will take precedent.
```bash
# ./secrets/flame_password
my_custom_secret_password_123
# ./docker-compose.yml
secrets:
password:
file: ./secrets/flame_password
```
#### Skaffold
```sh
# use skaffold
skaffold dev
```
### Without Docker
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
## Development
### Technology
- Backend
- Node.js + Express
- Sequelize ORM + SQLite
@ -15,9 +117,12 @@ Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI]
- TypeScript
- Deployment
- Docker
- Kubernetes
### Creating dev environment
## Development
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
cd flame
@ -28,31 +133,116 @@ npm run dev-init
npm run dev
```
## Deployment with Docker
```sh
# build image
docker build -t flame .
## Screenshots
# run container
docker run -p 5005:5005 -v <host_dir>:/app/data flame
![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
The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar).
### Setting up weather module
1. Obtain API Key from [Weather API](https://www.weatherapi.com/pricing.aspx).
> Free plan allows for 1M calls per month. Flame is making less then 3K API calls per month.
2. Get lat/long for your location. You can get them from [latlong.net](https://www.latlong.net/convert-address-to-lat-long.html).
3. Enter and save data. Weather widget will now update and should be visible on Home page.
### Docker integration
In order to use the Docker integration, each container must have the following labels:
```yml
labels:
- flame.type=application # "app" works too
- flame.name=My container
- flame.url=https://example.com
- flame.icon=icon-name # optional, default is "docker"
# - flame.icon=custom to make changes in app. ie: custom icon upload
```
## Functionality
- Applications
- Create, update and delete applications using GUI
- Pin your favourite apps to homescreen
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker
![Homescreen screenshot](./github/_apps.png)
You can also set up different apps in the same label adding `;` between each one.
- Bookmarks
- Create, update and delete bookmarks and categories using GUI
- Pin your favourite categories to homescreen
```yml
labels:
- flame.type=application
- flame.name=First App;Second App
- flame.url=https://example1.com;https://example2.com
- flame.icon=icon-name1;icon-name2
```
![Homescreen screenshot](./github/_bookmarks.png)
If you want to use a remote docker host follow this instructions in the host:
- Weather
- Get current temperature, cloud coverage and weather status with animated icons
- Themes
- Customize your page by choosing from 12 color themes
- Open the file `/lib/systemd/system/docker.service`, search for `ExecStart` and edit the value
![Homescreen screenshot](./github/_themes.png)
```text
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:${PORT} -H unix:///var/run/docker.sock
```
>The above command will bind the docker engine server to the Unix socket as well as TCP port of your choice. “0.0.0.0” means docker-engine accepts connections from all IP addresses.
- Restart the daemon and Docker service
```shell
sudo systemctl daemon-reload
sudo service docker restart
```
- Test if it is working
```shell
curl http://${IP}:${PORT}/version
```
### Kubernetes integration
In order to use the Kubernetes integration, each ingress must have the following annotations:
```yml
metadata:
annotations:
- flame.pawelmalak/type=application # "app" works too
- flame.pawelmalak/name=My container
- flame.pawelmalak/url=https://example.com
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
```
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker
### Import HTML Bookmarks (Experimental)
- Requirements
- python3
- pip packages: Pillow, beautifulsoup4
- Backup your `db.sqlite` before running script!
- Known Issues:
- generated icons are sometimes incorrect
```bash
pip3 install Pillow, beautifulsoup4
cd flame/.dev
python3 bookmarks_importer.py --bookmarks <path to bookmarks.html> --data <path to flame data folder>
```
### Custom CSS and themes
See project wiki for [Custom CSS](https://github.com/pawelmalak/flame/wiki/Custom-CSS) and [Custom theme with CSS](https://github.com/pawelmalak/flame/wiki/Custom-theme-with-CSS).

View file

@ -1,15 +1,17 @@
const WebSocket = require('ws');
const Logger = require('./utils/Logger');
const logger = new Logger();
class Socket {
constructor(server) {
this.webSocketServer = new WebSocket.Server({ server })
this.webSocketServer.on('listening', () => {
console.log('socket listen');
logger.log('Socket: listen');
})
this.webSocketServer.on('connection', (webSocketClient) => {
console.log('new connection');
// console.log('Socket: new connection');
})
}

16
api.js
View file

@ -1,14 +1,15 @@
const path = require('path');
const { join } = require('path');
const express = require('express');
const errorHandler = require('./middleware/errorHandler');
const { errorHandler } = require('./middleware');
const api = express();
// Static files
api.use(express.static(path.join(__dirname, 'public')));
api.use(express.static(join(__dirname, 'public')));
api.use('/uploads', express.static(join(__dirname, 'data/uploads')));
api.get(/^\/(?!api)/, (req, res) => {
res.sendFile(path.join(__dirname, 'public/index.html'));
})
res.sendFile(join(__dirname, 'public/index.html'));
});
// Body parser
api.use(express.json());
@ -19,8 +20,11 @@ api.use('/api/config', require('./routes/config'));
api.use('/api/weather', require('./routes/weather'));
api.use('/api/categories', require('./routes/category'));
api.use('/api/bookmarks', require('./routes/bookmark'));
api.use('/api/queries', require('./routes/queries'));
api.use('/api/auth', require('./routes/auth'));
api.use('/api/themes', require('./routes/themes'));
// Custom error handler
api.use(errorHandler);
module.exports = api;
module.exports = api;

1
client/.env Normal file
View file

@ -0,0 +1 @@
REACT_APP_VERSION=2.3.1

View file

@ -1,46 +0,0 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

33721
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

View file

@ -1,3 +1,2 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Disallow: /

View file

@ -1,41 +1,91 @@
import { useEffect } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { setTheme } from './store/actions';
import 'external-svg-loader';
// Redux
import store from './store/store';
import { Provider } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { autoLogin, getConfig } from './store/action-creators';
import { actionCreators, store } from './store';
import { State } from './store/reducers';
import classes from './App.module.css';
// Utils
import { checkVersion, decodeToken, parsePABToTheme } from './utility';
import Home from './components/Home/Home';
import Apps from './components/Apps/Apps';
import Settings from './components/Settings/Settings';
import Bookmarks from './components/Bookmarks/Bookmarks';
// Routes
import { Home } from './components/Home/Home';
import { Apps } from './components/Apps/Apps';
import { Settings } from './components/Settings/Settings';
import { Bookmarks } from './components/Bookmarks/Bookmarks';
import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
// Get config
store.dispatch<any>(getConfig());
if (localStorage.theme) {
store.dispatch<any>(setTheme(localStorage.theme));
// Validate token
if (localStorage.token) {
store.dispatch<any>(autoLogin());
}
if (localStorage.customTitle) {
document.title = localStorage.customTitle;
}
export const App = (): JSX.Element => {
const { config, loading } = useSelector((state: State) => state.config);
const dispath = useDispatch();
const { fetchQueries, setTheme, logout, createNotification, fetchThemes } =
bindActionCreators(actionCreators, dispath);
useEffect(() => {
// check if token is valid
const tokenIsValid = setInterval(() => {
if (localStorage.token) {
const expiresIn = decodeToken(localStorage.token).exp * 1000;
const now = new Date().getTime();
if (now > expiresIn) {
logout();
createNotification({
title: 'Info',
message: 'Session expired. You have been logged out',
});
}
}
}, 1000);
// load themes
fetchThemes();
// set user theme if present
if (localStorage.theme) {
setTheme(parsePABToTheme(localStorage.theme));
}
// check for updated
checkVersion();
// load custom search queries
fetchQueries();
return () => window.clearInterval(tokenIsValid);
}, []);
// If there is no user theme, set the default one
useEffect(() => {
if (!loading && !localStorage.theme) {
setTheme(parsePABToTheme(config.defaultTheme), false);
}
}, [loading]);
const App = (): JSX.Element => {
return (
<Provider store={store}>
<>
<BrowserRouter>
<Switch>
<Route exact path='/' component={Home} />
<Route path='/settings' component={Settings} />
<Route path='/applications' component={Apps} />
<Route path='/bookmarks' component={Bookmarks} />
<Route exact path="/" component={Home} />
<Route path="/settings" component={Settings} />
<Route path="/applications" component={Apps} />
<Route path="/bookmarks" component={Bookmarks} />
</Switch>
</BrowserRouter>
<NotificationCenter />
</Provider>
</>
);
}
export default App;
};

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

@ -27,4 +27,24 @@
font-weight: 400;
font-size: 0.8em;
opacity: 1;
}
}
@media (min-width: 500px) {
.AppCard {
padding: 2px;
border-radius: 4px;
transition: all 0.1s;
}
.AppCard:hover {
background-color: rgba(0, 0, 0, 0.2);
}
}
.CustomIcon {
width: 90%;
height: 90%;
margin-top: 2px;
margin-left: 2px;
object-fit: contain;
}

View file

@ -1,41 +1,61 @@
import { Link } from 'react-router-dom';
import classes from './AppCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { Icon } from '../../UI';
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
import { App } from '../../../interfaces';
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
interface ComponentProps {
interface Props {
app: App;
pinHandler?: Function;
}
const AppCard = (props: ComponentProps): JSX.Element => {
const iconParser = (mdiName: string): string => {
let parsedName = mdiName
.split('-')
.map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
.join('');
parsedName = `mdi${parsedName}`;
export const AppCard = ({ app }: Props): JSX.Element => {
const { config } = useSelector((state: State) => state.config);
return parsedName;
}
const [displayUrl, redirectUrl] = urlParser(app.url);
const redirectHandler = (url: string): void => {
window.open(url);
let iconEl: JSX.Element;
const { icon } = app;
if (isImage(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = (
<img
src={source}
alt={`${app.name} icon`}
className={classes.CustomIcon}
/>
);
} else if (isSvg(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = (
<div className={classes.CustomIcon}>
<svg
data-src={source}
fill="var(--color-primary)"
className={classes.CustomIcon}
></svg>
</div>
);
} else {
iconEl = <Icon icon={iconParser(icon)} />;
}
return (
<a href={`http://${props.app.url}`} target='blank' className={classes.AppCard}>
<div className={classes.AppCardIcon}>
<Icon icon={iconParser(props.app.icon)} />
</div>
<a
href={redirectUrl}
target={config.appsSameTab ? '' : '_blank'}
rel="noreferrer"
className={classes.AppCard}
>
<div className={classes.AppCardIcon}>{iconEl}</div>
<div className={classes.AppCardDetails}>
<h5>{props.app.name}</h5>
<span>{props.app.url}</span>
<h5>{app.name}</h5>
<span>{!app.description.length ? displayUrl : app.description}</span>
</div>
</a>
)
}
export default AppCard;
);
};

View file

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

View file

@ -1,131 +1,225 @@
import { useState, useEffect, useRef, ChangeEvent, SyntheticEvent } from 'react';
import { connect } from 'react-redux';
import { addApp, updateApp } from '../../../store/actions';
import { App, NewApp } from '../../../interfaces';
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { NewApp } from '../../../interfaces';
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
import classes from './AppForm.module.css';
interface ComponentProps {
import { ModalForm, InputGroup, Button } from '../../UI';
import { inputHandler, newAppTemplate } from '../../../utility';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
interface Props {
modalHandler: () => void;
addApp: (formData: NewApp) => any;
updateApp: (id: number, formData: NewApp) => any;
app?: App;
}
const AppForm = (props: ComponentProps): JSX.Element => {
const [formData, setFormData] = useState<NewApp>({
name: '',
url: '',
icon: ''
});
export const AppForm = ({ modalHandler }: Props): JSX.Element => {
const { appInUpdate } = useSelector((state: State) => state.apps);
const inputRef = useRef<HTMLInputElement>(null);
const dispatch = useDispatch();
const { addApp, updateApp, setEditApp, createNotification } =
bindActionCreators(actionCreators, dispatch);
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [formData, setFormData] = useState<NewApp>(newAppTemplate);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [inputRef])
useEffect(() => {
if (props.app) {
if (appInUpdate) {
setFormData({
name: props.app.name,
url: props.app.url,
icon: props.app.icon
})
...appInUpdate,
});
} else {
setFormData({
name: '',
url: '',
icon: ''
})
setFormData(newAppTemplate);
}
}, [props.app])
}, [appInUpdate]);
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<NewApp>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
if (e.target.files) {
setCustomIcon(e.target.files[0]);
}
};
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
if (!props.app) {
props.addApp(formData);
} else {
props.updateApp(props.app.id, formData);
props.modalHandler();
for (let field of ['name', 'url', 'icon'] as const) {
if (/^ +$/.test(formData[field])) {
createNotification({
title: 'Error',
message: `Field cannot be empty: ${field}`,
});
return;
}
}
setFormData({
name: '',
url: '',
icon: ''
})
}
const createFormData = (): FormData => {
const data = new FormData();
if (customIcon) {
data.append('icon', customIcon);
}
data.append('name', formData.name);
data.append('description', formData.description);
data.append('url', formData.url);
data.append('isPublic', `${formData.isPublic ? 1 : 0}`);
return data;
};
if (!appInUpdate) {
if (customIcon) {
const data = createFormData();
addApp(data);
} else {
addApp(formData);
}
} else {
if (customIcon) {
const data = createFormData();
updateApp(appInUpdate.id, data);
} else {
updateApp(appInUpdate.id, formData);
modalHandler();
}
}
setFormData(newAppTemplate);
setEditApp(null);
};
return (
<ModalForm
modalHandler={props.modalHandler}
formHandler={formSubmitHandler}
>
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
{/* NAME */}
<InputGroup>
<label htmlFor='name'>App Name</label>
<label htmlFor="name">App name</label>
<input
type='text'
name='name'
id='name'
placeholder='Bookstack'
type="text"
name="name"
id="name"
placeholder="Bookstack"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
ref={inputRef}
/>
</InputGroup>
{/* URL */}
<InputGroup>
<label htmlFor='url'>App URL</label>
<label htmlFor="url">App URL</label>
<input
type='text'
name='url'
id='url'
placeholder='bookstack.example.com'
type="text"
name="url"
id="url"
placeholder="bookstack.example.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Only urls without http[s]:// are supported</span>
</InputGroup>
{/* DESCRIPTION */}
<InputGroup>
<label htmlFor='icon'>App Icon</label>
<label htmlFor="description">App description</label>
<input
type='text'
name='icon'
id='icon'
placeholder='book-open-outline'
required
value={formData.icon}
type="text"
name="description"
id="description"
placeholder="My self-hosted app"
value={formData.description}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI.
<a
href='https://materialdesignicons.com/'
target='blank'>
{' '}Click here for reference
</a>
Optional - If description is not set, app URL will be displayed
</span>
</InputGroup>
{!props.app
? <Button>Add new application</Button>
: <Button>Update application</Button>
}
</ModalForm>
)
}
export default connect(null, { addApp, updateApp })(AppForm);
{/* ICON */}
{!useCustomIcon ? (
// use mdi icon
<InputGroup>
<label htmlFor="icon">App icon</label>
<input
type="text"
name="icon"
id="icon"
placeholder="book-open-outline"
required
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI or pass a valid URL.
<a href="https://pictogrammers.com/library/mdi/" target="blank">
{' '}
Click here for reference
</a>
</span>
<span
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
className={classes.Switch}
>
Switch to custom icon upload
</span>
</InputGroup>
) : (
// upload custom icon
<InputGroup>
<label htmlFor="icon">App Icon</label>
<input
type="file"
name="icon"
id="icon"
required
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg,.ico"
/>
<span
onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch}
>
Switch to MDI
</span>
</InputGroup>
)}
{/* VISIBILITY */}
<InputGroup>
<label htmlFor="isPublic">App visibility</label>
<select
id="isPublic"
name="isPublic"
value={formData.isPublic ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Visible (anyone can access it)</option>
<option value={0}>Hidden (authentication required)</option>
</select>
</InputGroup>
{!appInUpdate ? (
<Button>Add new application</Button>
) : (
<Button>Update application</Button>
)}
</ModalForm>
);
};

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,40 +2,47 @@ import classes from './AppGrid.module.css';
import { Link } from 'react-router-dom';
import { App } from '../../../interfaces/App';
import AppCard from '../AppCard/AppCard';
import { AppCard } from '../AppCard/AppCard';
import { Message } from '../../UI';
interface ComponentProps {
interface Props {
apps: App[];
totalApps?: number;
searching: boolean;
}
const AppGrid = (props: ComponentProps): JSX.Element => {
export const AppGrid = (props: Props): JSX.Element => {
let apps: JSX.Element;
if (props.apps.length > 0) {
apps = (
<div className={classes.AppGrid}>
{props.apps.map((app: App): JSX.Element => {
return <AppCard
key={app.id}
app={app}
/>
})}
</div>
)
if (props.searching || props.apps.length) {
if (!props.apps.length) {
apps = <Message>No apps match your search criteria</Message>;
} else {
apps = (
<div className={classes.AppGrid}>
{props.apps.map((app: App): JSX.Element => {
return <AppCard key={app.id} app={app} />;
})}
</div>
);
}
} else {
if (props.totalApps) {
apps = (
<p className={classes.AppsMessage}>There are no pinned applications. You can pin them from the <Link to='/applications'>/applications</Link> menu</p>
<Message>
There are no pinned applications. You can pin them from the{' '}
<Link to="/applications">/applications</Link> menu
</Message>
);
} else {
apps = (
<p className={classes.AppsMessage}>You don't have any applications. You can add a new one from <Link to='/applications'>/applications</Link> menu</p>
<Message>
You don't have any applications. You can add a new one from{' '}
<Link to="/applications">/applications</Link> menu
</Message>
);
}
}
return apps;
}
export default AppGrid;
};

View file

@ -1,84 +1,160 @@
import { KeyboardEvent } from 'react';
import { connect } from 'react-redux';
import { App, GlobalState } from '../../../interfaces';
import { pinApp, deleteApp } from '../../../store/actions';
import { Fragment, useState, useEffect } from 'react';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from 'react-beautiful-dnd';
import { Link } from 'react-router-dom';
import classes from './AppTable.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import Table from '../../UI/Table/Table';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
interface ComponentProps {
apps: App[];
pinApp: (app: App) => void;
deleteApp: (id: number) => void;
updateAppHandler: (app: App) => void;
// Typescript
import { App } from '../../../interfaces';
// Other
import { Message, Table } from '../../UI';
import { TableActions } from '../../Actions/TableActions';
interface Props {
openFormForUpdating: (app: App) => void;
}
const AppTable = (props: ComponentProps): JSX.Element => {
const deleteAppHandler = (app: App): void => {
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
export const AppTable = (props: Props): JSX.Element => {
const {
apps: { apps },
config: { config },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { pinApp, deleteApp, reorderApps, createNotification, updateApp } =
bindActionCreators(actionCreators, dispatch);
const [localApps, setLocalApps] = useState<App[]>([]);
// Copy apps array
useEffect(() => {
setLocalApps([...apps]);
}, [apps]);
const dragEndHanlder = (result: DropResult): void => {
if (config.useOrdering !== 'orderId') {
createNotification({
title: 'Error',
message: 'Custom order is disabled',
});
return;
}
if (!result.destination) {
return;
}
const tmpApps = [...localApps];
const [movedApp] = tmpApps.splice(result.source.index, 1);
tmpApps.splice(result.destination.index, 0, movedApp);
setLocalApps(tmpApps);
reorderApps(tmpApps);
};
// Action handlers
const deleteAppHandler = (id: number, name: string) => {
const proceed = window.confirm(`Are you sure you want to delete ${name}?`);
if (proceed) {
props.deleteApp(app.id);
deleteApp(id);
}
}
};
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
if (e.key === 'Enter') {
handler(app);
}
}
const updateAppHandler = (id: number) => {
const app = apps.find((a) => a.id === id) as App;
props.openFormForUpdating(app);
};
const pinAppHandler = (id: number) => {
const app = apps.find((a) => a.id === id) as App;
pinApp(app);
};
const changeAppVisibiltyHandler = (id: number) => {
const app = apps.find((a) => a.id === id) as App;
updateApp(id, { ...app, isPublic: !app.isPublic });
};
return (
<Table headers={[
'Name',
'URL',
'Icon',
'Actions'
]}>
{props.apps.map((app: App): JSX.Element => {
return (
<tr key={app.id}>
<td>{app.name}</td>
<td>{app.url}</td>
<td>{app.icon}</td>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteAppHandler(app)}
onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
tabIndex={0}>
<Icon icon='mdiDelete' />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateAppHandler(app)}
onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
tabIndex={0}>
<Icon icon='mdiPencil' />
</div>
<div
className={classes.TableAction}
onClick={() => props.pinApp(app)}
onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
tabIndex={0}>
{app.isPinned
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
: <Icon icon='mdiPin' />
}
</div>
</td>
</tr>
)
})}
</Table>
)
}
<Fragment>
<Message isPrimary={false}>
{config.useOrdering === 'orderId' ? (
<p>You can drag and drop single rows to reorder application</p>
) : (
<p>
Custom order is disabled. You can change it in the{' '}
<Link to="/settings/general">settings</Link>
</p>
)}
</Message>
const mapStateToProps = (state: GlobalState) => {
return {
apps: state.app.apps
}
}
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="apps">
{(provided) => (
<Table
headers={['Name', 'URL', 'Icon', 'Visibility', 'Actions']}
innerRef={provided.innerRef}
>
{localApps.map((app: App, index): JSX.Element => {
return (
<Draggable
key={app.id}
draggableId={app.id.toString()}
index={index}
>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging
? '1px solid var(--color-accent)'
: 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
export default connect(mapStateToProps, { pinApp, deleteApp })(AppTable);
return (
<tr
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={style}
>
<td style={{ width: '200px' }}>{app.name}</td>
<td style={{ width: '200px' }}>{app.url}</td>
<td style={{ width: '200px' }}>{app.icon}</td>
<td style={{ width: '200px' }}>
{app.isPublic ? 'Visible' : 'Hidden'}
</td>
{!snapshot.isDragging && (
<TableActions
entity={app}
deleteHandler={deleteAppHandler}
updateHandler={updateAppHandler}
pinHanlder={pinAppHandler}
changeVisibilty={changeAppVisibiltyHandler}
/>
)}
</tr>
);
}}
</Draggable>
);
})}
</Table>
)}
</Droppable>
</DragDropContext>
</Fragment>
);
};

View file

@ -1,114 +1,110 @@
import { Fragment, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
// Redux
import { connect } from 'react-redux';
import { getApps } from '../../store/actions';
import { useDispatch, useSelector } from 'react-redux';
// Typescript
import { App, GlobalState } from '../../interfaces';
import { App } from '../../interfaces';
// CSS
import classes from './Apps.module.css';
// UI
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
import Spinner from '../UI/Spinner/Spinner';
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
import Modal from '../UI/Modal/Modal';
import { Headline, Spinner, ActionButton, Modal, Container } from '../UI';
// Subcomponents
import AppGrid from './AppGrid/AppGrid';
import AppForm from './AppForm/AppForm';
import AppTable from './AppTable/AppTable';
import { AppGrid } from './AppGrid/AppGrid';
import { AppForm } from './AppForm/AppForm';
import { AppTable } from './AppTable/AppTable';
interface ComponentProps {
getApps: Function;
apps: App[];
loading: boolean;
// Utils
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
interface Props {
searching: boolean;
}
const Apps = (props: ComponentProps): JSX.Element => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [isInEdit, setIsInEdit] = useState(false);
const [isInUpdate, setIsInUpdate] = useState(false);
const [appInUpdate, setAppInUpdate] = useState<App>({
name: 'string',
url: 'string',
icon: 'string',
isPinned: false,
id: 0,
createdAt: new Date(),
updatedAt: new Date()
})
export const Apps = (props: Props): JSX.Element => {
// Get Redux state
const {
apps: { apps, loading },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
// Get Redux action creators
const dispatch = useDispatch();
const { getApps, setEditApp } = bindActionCreators(actionCreators, dispatch);
// Load apps if array is empty
useEffect(() => {
if (props.apps.length === 0) {
props.getApps();
if (!apps.length) {
getApps();
}
}, [props.getApps]);
}, []);
// Form
const [modalIsOpen, setModalIsOpen] = useState(false);
const [showTable, setShowTable] = useState(false);
// Observe if user is authenticated -> set default view if not
useEffect(() => {
if (!isAuthenticated) {
setShowTable(false);
setModalIsOpen(false);
}
}, [isAuthenticated]);
// Form actions
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
setIsInUpdate(false);
}
};
const toggleEdit = (): void => {
setIsInEdit(!isInEdit);
setIsInUpdate(false);
}
setShowTable(!showTable);
};
const toggleUpdate = (app: App): void => {
setAppInUpdate(app);
setIsInUpdate(true);
const openFormForUpdating = (app: App): void => {
setEditApp(app);
setModalIsOpen(true);
}
};
return (
<Container>
<Modal isOpen={modalIsOpen} setIsOpen={setModalIsOpen}>
{!isInUpdate
? <AppForm modalHandler={toggleModal} />
: <AppForm modalHandler={toggleModal} app={appInUpdate} />
}
<AppForm modalHandler={toggleModal} />
</Modal>
<Headline
title='All Applications'
subtitle={(<Link to='/'>Go back</Link>)}
title="All Applications"
subtitle={<Link to="/">Go back</Link>}
/>
<div className={classes.ActionsContainer}>
<ActionButton
name='Add'
icon='mdiPlusBox'
handler={toggleModal}
/>
<ActionButton
name='Edit'
icon='mdiPencil'
handler={toggleEdit}
/>
</div>
{isAuthenticated && (
<div className={classes.ActionsContainer}>
<ActionButton
name="Add"
icon="mdiPlusBox"
handler={() => {
setEditApp(null);
toggleModal();
}}
/>
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
</div>
)}
<div className={classes.Apps}>
{props.loading
? <Spinner />
: (!isInEdit
? <AppGrid apps={props.apps} />
: <AppTable updateAppHandler={toggleUpdate} />)
}
{loading ? (
<Spinner />
) : !showTable ? (
<AppGrid apps={apps} searching={props.searching} />
) : (
<AppTable openFormForUpdating={openFormForUpdating} />
)}
</div>
</Container>
)
}
const mapStateToProps = (state: GlobalState) => {
return {
apps: state.app.apps,
loading: state.app.loading
}
}
export default connect(mapStateToProps, { getApps })(Apps);
);
};

View file

@ -10,6 +10,10 @@
text-transform: uppercase;
}
.BookmarkHeader:hover {
cursor: pointer;
}
.Bookmarks {
display: flex;
flex-direction: column;
@ -18,9 +22,35 @@
.Bookmarks a {
line-height: 2;
transition: all 0.25s;
display: flex;
}
.BookmarkCard a:hover {
text-decoration: underline;
padding-left: 10px;
}
}
.BookmarkIcon {
width: 20px;
height: 20px;
display: flex;
margin-top: 3px;
margin-right: 2px;
justify-content: center;
align-items: center;
}
.BookmarkIconSvg {
width: 80%;
height: 80%;
margin-top: 2px;
margin-left: 2px;
object-fit: contain;
}
.CustomIcon {
width: 90%;
height: 90%;
margin-top: 2px;
object-fit: contain;
}

View file

@ -1,26 +1,105 @@
import { Bookmark, Category } from '../../../interfaces';
import classes from './BookmarkCard.module.css';
import { Fragment } from 'react';
interface ComponentProps {
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { Bookmark, Category } from '../../../interfaces';
// Other
import classes from './BookmarkCard.module.css';
import { Icon } from '../../UI';
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
interface Props {
category: Category;
fromHomepage?: boolean;
}
const BookmarkCard = (props: ComponentProps): JSX.Element => {
export const BookmarkCard = (props: Props): JSX.Element => {
const { category, fromHomepage = false } = props;
const {
config: { config },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { setEditCategory } = bindActionCreators(actionCreators, dispatch);
return (
<div className={classes.BookmarkCard}>
<h3>{props.category.name}</h3>
<h3
className={
fromHomepage || !isAuthenticated ? '' : classes.BookmarkHeader
}
onClick={() => {
if (!fromHomepage && isAuthenticated) {
setEditCategory(category);
}
}}
>
{category.name}
</h3>
<div className={classes.Bookmarks}>
{props.category.bookmarks.map((bookmark: Bookmark) => (
<a
href={`http://${bookmark.url}`}
target='blank'
key={`bookmark-${bookmark.id}`}>
{bookmark.name}
</a>
))}
{category.bookmarks.map((bookmark: Bookmark) => {
const redirectUrl = urlParser(bookmark.url)[1];
let iconEl: JSX.Element = <Fragment></Fragment>;
if (bookmark.icon) {
const { icon, name } = bookmark;
if (isImage(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = (
<div className={classes.BookmarkIcon}>
<img
src={source}
alt={`${name} icon`}
className={classes.CustomIcon}
/>
</div>
);
} else if (isSvg(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = (
<div className={classes.BookmarkIcon}>
<svg
data-src={source}
fill="var(--color-primary)"
className={classes.BookmarkIconSvg}
></svg>
</div>
);
} else {
iconEl = (
<div className={classes.BookmarkIcon}>
<Icon icon={iconParser(icon)} />
</div>
);
}
}
return (
<a
href={redirectUrl}
target={config.bookmarksSameTab ? '' : '_blank'}
rel="noreferrer"
key={`bookmark-${bookmark.id}`}
>
{bookmark.icon && iconEl}
{bookmark.name}
</a>
);
})}
</div>
</div>
)
}
export default BookmarkCard;
);
};

View file

@ -1,227 +0,0 @@
import { useState, SyntheticEvent, Fragment, ChangeEvent, useEffect } from 'react';
import { connect } from 'react-redux';
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import { Bookmark, Category, GlobalState, NewBookmark, NewCategory, NewNotification } from '../../../interfaces';
import { ContentType } from '../Bookmarks';
import { getCategories, addCategory, addBookmark, updateCategory, updateBookmark, createNotification } from '../../../store/actions';
import Button from '../../UI/Buttons/Button/Button';
interface ComponentProps {
modalHandler: () => void;
contentType: ContentType;
categories: Category[];
category?: Category;
bookmark?: Bookmark;
addCategory: (formData: NewCategory) => void;
addBookmark: (formData: NewBookmark) => void;
updateCategory: (id: number, formData: NewCategory) => void;
updateBookmark: (id: number, formData: NewBookmark, previousCategoryId: number) => void;
createNotification: (notification: NewNotification) => void;
}
const BookmarkForm = (props: ComponentProps): JSX.Element => {
const [categoryName, setCategoryName] = useState<NewCategory>({
name: ''
})
const [formData, setFormData] = useState<NewBookmark>({
name: '',
url: '',
categoryId: -1
})
useEffect(() => {
if (props.category) {
setCategoryName({ name: props.category.name });
} else {
setCategoryName({ name: '' });
}
}, [props.category])
useEffect(() => {
if (props.bookmark) {
setFormData({
name: props.bookmark.name,
url: props.bookmark.url,
categoryId: props.bookmark.categoryId
})
} else {
setFormData({
name: '',
url: '',
categoryId: -1
})
}
}, [props.bookmark])
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
if (!props.category && !props.bookmark) {
// Add new
if (props.contentType === ContentType.category) {
// Add category
props.addCategory(categoryName);
setCategoryName({ name: '' });
} else if (props.contentType === ContentType.bookmark) {
// Add bookmark
if (formData.categoryId === -1) {
props.createNotification({
title: 'Error',
message: 'Please select category'
})
return;
}
props.addBookmark(formData);
setFormData({
name: '',
url: '',
categoryId: formData.categoryId
})
}
} else {
// Update
if (props.contentType === ContentType.category && props.category) {
// Update category
props.updateCategory(props.category.id, categoryName);
setCategoryName({ name: '' });
} else if (props.contentType === ContentType.bookmark && props.bookmark) {
// Update bookmark
props.updateBookmark(props.bookmark.id, formData, props.bookmark.categoryId);
setFormData({
name: '',
url: '',
categoryId: -1
})
}
props.modalHandler();
}
}
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
setFormData({
...formData,
categoryId: parseInt(e.target.value)
})
}
let button = <Button>Submit</Button>
if (!props.category && !props.bookmark) {
if (props.contentType === ContentType.category) {
button = <Button>Add new category</Button>;
} else {
button = <Button>Add new bookmark</Button>;
}
} else if (props.category) {
button = <Button>Update category</Button>
} else if (props.bookmark) {
button = <Button>Update bookmark</Button>
}
return (
<ModalForm
modalHandler={props.modalHandler}
formHandler={formSubmitHandler}
>
{props.contentType === ContentType.category
? (
<Fragment>
<InputGroup>
<label htmlFor='categoryName'>Category Name</label>
<input
type='text'
name='categoryName'
id='categoryName'
placeholder='Social Media'
required
value={categoryName.name}
onChange={(e) => setCategoryName({ name: e.target.value })}
/>
</InputGroup>
</Fragment>
)
: (
<Fragment>
<InputGroup>
<label htmlFor='name'>Bookmark Name</label>
<input
type='text'
name='name'
id='name'
placeholder='Reddit'
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor='url'>Bookmark URL</label>
<input
type='text'
name='url'
id='url'
placeholder='reddit.com'
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Only urls without http[s]:// are supported</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>
</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

@ -2,37 +2,63 @@ import { Link } from 'react-router-dom';
import classes from './BookmarkGrid.module.css';
import { Bookmark, Category } from '../../../interfaces';
import { Category } from '../../../interfaces';
import BookmarkCard from '../BookmarkCard/BookmarkCard';
import { BookmarkCard } from '../BookmarkCard/BookmarkCard';
import { Message } from '../../UI';
interface ComponentProps {
interface Props {
categories: Category[];
totalCategories?: number;
searching: boolean;
fromHomepage?: boolean;
}
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
export const BookmarkGrid = (props: Props): JSX.Element => {
const {
categories,
totalCategories,
searching,
fromHomepage = false,
} = props;
let bookmarks: JSX.Element;
if (props.categories.length > 0) {
bookmarks = (
<div className={classes.BookmarkGrid}>
{props.categories.map((category: Category): JSX.Element => <BookmarkCard category={category} key={category.id} />)}
</div>
);
} else {
if (props.totalCategories) {
if (categories.length) {
if (searching && !categories[0].bookmarks.length) {
bookmarks = <Message>No bookmarks match your search criteria</Message>;
} else {
bookmarks = (
<p className={classes.BookmarksMessage}>There are no pinned categories. You can pin them from the <Link to='/bookmarks'>/bookmarks</Link> menu</p>
<div className={classes.BookmarkGrid}>
{categories.map(
(category: Category): JSX.Element => (
<BookmarkCard
category={category}
fromHomepage={fromHomepage}
key={category.id}
/>
)
)}
</div>
);
}
} else {
if (totalCategories) {
bookmarks = (
<Message>
There are no pinned categories. You can pin them from the{' '}
<Link to="/bookmarks">/bookmarks</Link> menu
</Message>
);
} else {
bookmarks = (
<p className={classes.BookmarksMessage}>You don't have any bookmarks. You can add a new one from <Link to='/bookmarks'>/bookmarks</Link> menu</p>
<Message>
You don't have any bookmarks. You can add a new one from{' '}
<Link to="/bookmarks">/bookmarks</Link> menu
</Message>
);
}
}
return bookmarks;
}
export default BookmarkGrid;
};

View file

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

View file

@ -1,132 +0,0 @@
import { ContentType } from '../Bookmarks';
import classes from './BookmarkTable.module.css';
import { connect } from 'react-redux';
import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions';
import { KeyboardEvent } from 'react';
import Table from '../../UI/Table/Table';
import { Bookmark, Category } from '../../../interfaces';
import Icon from '../../UI/Icons/Icon/Icon';
interface ComponentProps {
contentType: ContentType;
categories: Category[];
pinCategory: (category: Category) => void;
deleteCategory: (id: number) => void;
updateHandler: (data: Category | Bookmark) => void;
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
}
const BookmarkTable = (props: ComponentProps): JSX.Element => {
const deleteCategoryHandler = (category: Category): void => {
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
if (proceed) {
props.deleteCategory(category.id);
}
}
const deleteBookmarkHandler = (bookmark: Bookmark): void => {
const proceed = window.confirm(`Are you sure you want to delete ${bookmark.name}?`);
if (proceed) {
props.deleteBookmark(bookmark.id, bookmark.categoryId);
}
}
const keyboardActionHandler = (e: KeyboardEvent, category: Category, handler: Function) => {
if (e.key === 'Enter') {
handler(category);
}
}
if (props.contentType === ContentType.category) {
return (
<Table headers={[
'Name',
'Actions'
]}>
{props.categories.map((category: Category) => {
return (
<tr key={category.id}>
<td>{category.name}</td>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteCategoryHandler(category)}
onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)}
tabIndex={0}>
<Icon icon='mdiDelete' />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateHandler(category)}
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
tabIndex={0}>
<Icon icon='mdiPencil' />
</div>
<div
className={classes.TableAction}
onClick={() => props.pinCategory(category)}
onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)}
tabIndex={0}>
{category.isPinned
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
: <Icon icon='mdiPin' />
}
</div>
</td>
</tr>
)
})}
</Table>
)
} else {
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
props.categories.forEach((category: Category) => {
category.bookmarks.forEach((bookmark: Bookmark) => {
bookmarks.push({
bookmark,
categoryName: category.name
});
})
})
return (
<Table headers={[
'Name',
'URL',
'Category',
'Actions'
]}>
{bookmarks.map((bookmark: {bookmark: Bookmark, categoryName: string}) => {
return (
<tr key={bookmark.bookmark.id}>
<td>{bookmark.bookmark.name}</td>
<td>{bookmark.bookmark.url}</td>
<td>{bookmark.categoryName}</td>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
// onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
tabIndex={0}>
<Icon icon='mdiDelete' />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateHandler(bookmark.bookmark)}
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
tabIndex={0}>
<Icon icon='mdiPencil' />
</div>
</td>
</tr>
)
})}
</Table>
)
}
}
export default connect(null, { pinCategory, deleteCategory, deleteBookmark })(BookmarkTable);

View file

@ -1,156 +1,195 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import { getCategories } from '../../store/actions';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
// Typescript
import { Category, Bookmark } from '../../interfaces';
// CSS
import classes from './Bookmarks.module.css';
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
// UI
import {
Container,
Headline,
ActionButton,
Spinner,
Modal,
Message,
} from '../UI';
import BookmarkGrid from './BookmarkGrid/BookmarkGrid';
import { Category, GlobalState, Bookmark } from '../../interfaces';
import Spinner from '../UI/Spinner/Spinner';
import Modal from '../UI/Modal/Modal';
import BookmarkForm from './BookmarkForm/BookmarkForm';
import BookmarkTable from './BookmarkTable/BookmarkTable';
// Components
import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
import { Form } from './Form/Form';
import { Table } from './Table/Table';
interface ComponentProps {
loading: boolean;
categories: Category[];
getCategories: () => void;
interface Props {
searching: boolean;
}
export enum ContentType {
category,
bookmark
bookmark,
}
const Bookmarks = (props: ComponentProps): JSX.Element => {
export const Bookmarks = (props: Props): JSX.Element => {
// Get Redux state
const {
bookmarks: { loading, categories, categoryInEdit },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
// Get Redux action creators
const dispatch = useDispatch();
const { getCategories, setEditCategory, setEditBookmark } =
bindActionCreators(actionCreators, dispatch);
// Load categories if array is empty
useEffect(() => {
if (!categories.length) {
getCategories();
}
}, []);
// Form
const [modalIsOpen, setModalIsOpen] = useState(false);
const [formContentType, setFormContentType] = useState(ContentType.category);
const [isInEdit, setIsInEdit] = useState(false);
const [tableContentType, setTableContentType] = useState(ContentType.category);
const [isInUpdate, setIsInUpdate] = useState(false);
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
name: '',
id: -1,
isPinned: false,
bookmarks: [],
createdAt: new Date(),
updatedAt: new Date()
})
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
name: '',
url: '',
categoryId: -1,
id: -1,
createdAt: new Date(),
updatedAt: new Date()
})
// Table
const [showTable, setShowTable] = useState(false);
const [tableContentType, setTableContentType] = useState(
ContentType.category
);
// Observe if user is authenticated -> set default view (grid) if not
useEffect(() => {
if (!isAuthenticated) {
setShowTable(false);
setModalIsOpen(false);
}
}, [isAuthenticated]);
useEffect(() => {
if (props.categories.length === 0) {
props.getCategories();
if (categoryInEdit && !modalIsOpen) {
setTableContentType(ContentType.bookmark);
setShowTable(true);
}
}, [props.getCategories])
}, [categoryInEdit]);
useEffect(() => {
setShowTable(false);
setEditCategory(null);
}, []);
// Form actions
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
}
};
const addActionHandler = (contentType: ContentType) => {
const openFormForAdding = (contentType: ContentType) => {
setFormContentType(contentType);
setIsInUpdate(false);
toggleModal();
}
};
const editActionHandler = (contentType: ContentType) => {
// We're in the edit mode and the same button was clicked - go back to list
if (isInEdit && contentType === tableContentType) {
setIsInEdit(false);
} else {
setIsInEdit(true);
setTableContentType(contentType);
}
}
const instanceOfCategory = (object: any): object is Category => {
return 'bookmarks' in object;
}
const goToUpdateMode = (data: Category | Bookmark): void => {
const openFormForUpdating = (data: Category | Bookmark): void => {
setIsInUpdate(true);
const instanceOfCategory = (object: any): object is Category => {
return 'bookmarks' in object;
};
if (instanceOfCategory(data)) {
setFormContentType(ContentType.category);
setCategoryInUpdate(data);
setEditCategory(data);
} else {
setFormContentType(ContentType.bookmark);
setBookmarkInUpdate(data);
setEditBookmark(data);
}
toggleModal();
}
};
// Table actions
const showTableForEditing = (contentType: ContentType) => {
// We're in the edit mode and the same button was clicked - go back to list
if (showTable && contentType === tableContentType) {
setEditCategory(null);
setShowTable(false);
} else {
setShowTable(true);
setTableContentType(contentType);
}
};
const finishEditing = () => {
setShowTable(false);
setEditCategory(null);
};
return (
<Container>
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
{!isInUpdate
? <BookmarkForm modalHandler={toggleModal} contentType={formContentType} />
: formContentType === ContentType.category
? <BookmarkForm modalHandler={toggleModal} contentType={formContentType} category={categoryInUpdate} />
: <BookmarkForm modalHandler={toggleModal} contentType={formContentType} bookmark={bookmarkInUpdate} />
}
<Form
modalHandler={toggleModal}
contentType={formContentType}
inUpdate={isInUpdate}
/>
</Modal>
<Headline
title='All Bookmarks'
subtitle={(<Link to='/'>Go back</Link>)}
/>
<div className={classes.ActionsContainer}>
<ActionButton
name='Add Category'
icon='mdiPlusBox'
handler={() => addActionHandler(ContentType.category)}
/>
<ActionButton
name='Add Bookmark'
icon='mdiPlusBox'
handler={() => addActionHandler(ContentType.bookmark)}
/>
<ActionButton
name='Edit Categories'
icon='mdiPencil'
handler={() => editActionHandler(ContentType.category)}
/>
<ActionButton
name='Edit Bookmarks'
icon='mdiPencil'
handler={() => editActionHandler(ContentType.bookmark)}
/>
</div>
<Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
{props.loading
? <Spinner />
: (!isInEdit
? <BookmarkGrid categories={props.categories} />
: <BookmarkTable
contentType={tableContentType}
categories={props.categories}
updateHandler={goToUpdateMode}
{isAuthenticated && (
<div className={classes.ActionsContainer}>
<ActionButton
name="Add Category"
icon="mdiPlusBox"
handler={() => openFormForAdding(ContentType.category)}
/>
<ActionButton
name="Add Bookmark"
icon="mdiPlusBox"
handler={() => openFormForAdding(ContentType.bookmark)}
/>
<ActionButton
name="Edit Categories"
icon="mdiPencil"
handler={() => showTableForEditing(ContentType.category)}
/>
{showTable && tableContentType === ContentType.bookmark && (
<ActionButton
name="Finish Editing"
icon="mdiPencil"
handler={finishEditing}
/>
)
}
)}
</div>
)}
{categories.length && isAuthenticated && !showTable ? (
<Message isPrimary={false}>
Click on category name to edit its bookmarks
</Message>
) : (
<></>
)}
{loading ? (
<Spinner />
) : !showTable ? (
<BookmarkGrid categories={categories} searching={props.searching} />
) : (
<Table
contentType={tableContentType}
openFormForUpdating={openFormForUpdating}
/>
)}
</Container>
)
}
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.bookmark.loading,
categories: state.bookmark.categories
}
}
export default connect(mapStateToProps, { getCategories })(Bookmarks);
);
};

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,7 @@
.Switch {
text-decoration: underline;
}
.Switch:hover {
cursor: pointer;
}

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

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

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
// CSS
import classes from './Header.module.css';
// Components
import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
// Utils
import { getDateTime } from './functions/getDateTime';
import { greeter } from './functions/greeter';
export const Header = (): JSX.Element => {
const { hideHeader, hideDate, showTime } = useSelector(
(state: State) => state.config.config
);
const [dateTime, setDateTime] = useState<string>(getDateTime());
const [greeting, setGreeting] = useState<string>(greeter());
useEffect(() => {
let dateTimeInterval: NodeJS.Timeout;
dateTimeInterval = setInterval(() => {
setDateTime(getDateTime());
setGreeting(greeter());
}, 1000);
return () => window.clearInterval(dateTimeInterval);
}, []);
return (
<header className={classes.Header}>
{(!hideDate || showTime) && <p>{dateTime}</p>}
<Link to="/settings" className={classes.SettingsLink}>
Go to Settings
</Link>
{!hideHeader && (
<span className={classes.HeaderMain}>
<h1>{greeting}</h1>
<WeatherWidget />
</span>
)}
</header>
);
};

View file

@ -0,0 +1,71 @@
import { parseTime } from '../../../../utility';
export const getDateTime = (): string => {
const days = localStorage.getItem('daySchema')?.split(';') || [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const months = localStorage.getItem('monthSchema')?.split(';') || [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const now = new Date();
const useAmericanDate = localStorage.useAmericanDate === 'true';
const showTime = localStorage.showTime === 'true';
const hideDate = localStorage.hideDate === 'true';
// Date
let dateEl = '';
if (!hideDate) {
if (!useAmericanDate) {
dateEl = `${days[now.getDay()]}, ${now.getDate()} ${
months[now.getMonth()]
} ${now.getFullYear()}`;
} else {
dateEl = `${days[now.getDay()]}, ${
months[now.getMonth()]
} ${now.getDate()} ${now.getFullYear()}`;
}
}
// Time
const p = parseTime;
let timeEl = '';
if (showTime) {
const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p(
now.getSeconds()
)}`;
timeEl = time;
}
// Separator
let separator = '';
if (!hideDate && showTime) {
separator = ' - ';
}
// Output
return `${dateEl}${separator}${timeEl}`;
};

View file

@ -0,0 +1,17 @@
export const greeter = (): string => {
const now = new Date().getHours();
let msg: string;
const greetingsSchemaRaw =
localStorage.getItem('greetingsSchema') ||
'Good evening!;Good afternoon!;Good morning!;Good night!';
const greetingsSchema = greetingsSchemaRaw.split(';');
if (now >= 18) msg = greetingsSchema[0];
else if (now >= 12) msg = greetingsSchema[1];
else if (now >= 6) msg = greetingsSchema[2];
else if (now >= 0) msg = greetingsSchema[3];
else msg = 'Hello!';
return msg;
};

View file

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

View file

@ -1,117 +1,169 @@
import { useEffect } from 'react';
import { useState, useEffect, Fragment } from 'react';
import { Link } from 'react-router-dom';
// Redux
import { connect } from 'react-redux';
import { getApps, getCategories } from '../../store/actions';
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
// Typescript
import { GlobalState } from '../../interfaces/GlobalState';
import { App, Category } from '../../interfaces';
// UI
import Icon from '../UI/Icons/Icon/Icon';
import { Container } from '../UI/Layout/Layout';
import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline';
import Spinner from '../UI/Spinner/Spinner';
import { Icon, Container, SectionHeadline, Spinner, Message } from '../UI';
// CSS
import classes from './Home.module.css';
// Components
import AppGrid from '../Apps/AppGrid/AppGrid';
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget';
import { AppGrid } from '../Apps/AppGrid/AppGrid';
import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
import { SearchBar } from '../SearchBar/SearchBar';
import { Header } from './Header/Header';
interface ComponentProps {
getApps: Function;
getCategories: Function;
appsLoading: boolean;
apps: App[];
categoriesLoading: boolean;
categories: Category[];
}
// Utils
import { escapeRegex } from '../../utility';
const Home = (props: ComponentProps): JSX.Element => {
export const Home = (): JSX.Element => {
const {
apps: { apps, loading: appsLoading },
bookmarks: { categories, loading: bookmarksLoading },
config: { config },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { getApps, getCategories } = bindActionCreators(
actionCreators,
dispatch
);
// Local search query
const [localSearch, setLocalSearch] = useState<null | string>(null);
const [appSearchResult, setAppSearchResult] = useState<null | App[]>(null);
const [bookmarkSearchResult, setBookmarkSearchResult] = useState<
null | Category[]
>(null);
// Load applications
useEffect(() => {
if (props.apps.length === 0) {
props.getApps();
if (!apps.length) {
getApps();
}
}, [props.getApps]);
}, []);
// Load bookmark categories
useEffect(() => {
if (!categories.length) {
getCategories();
}
}, []);
useEffect(() => {
if (props.categories.length === 0) {
props.getCategories();
if (localSearch) {
// Search through apps
setAppSearchResult([
...apps.filter(({ name, description }) =>
new RegExp(escapeRegex(localSearch), 'i').test(
`${name} ${description}`
)
),
]);
// Search through bookmarks
const category = { ...categories[0] };
category.name = 'Search Results';
category.bookmarks = categories
.map(({ bookmarks }) => bookmarks)
.flat()
.filter(({ name }) =>
new RegExp(escapeRegex(localSearch), 'i').test(name)
);
setBookmarkSearchResult([category]);
} else {
setAppSearchResult(null);
setBookmarkSearchResult(null);
}
}, [props.getCategories]);
const dateAndTime = (): string => {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const now = new Date();
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
}
const greeter = (): string => {
const now = new Date().getHours();
let msg: string;
if (now >= 18) msg = 'Good evening!';
else if (now >= 12) msg = 'Good afternoon!';
else if (now >= 6) msg = 'Good morning!';
else if (now >= 0) msg = 'Good night!';
else msg = 'Hello!';
return msg;
}
}, [localSearch]);
return (
<Container>
<header className={classes.Header}>
<p>{dateAndTime()}</p>
<Link to='/settings' className={classes.SettingsLink}>Go to Settings</Link>
<span className={classes.HeaderMain}>
<h1>{greeter()}</h1>
<WeatherWidget />
</span>
</header>
<SectionHeadline title='Applications' link='/applications' />
{props.appsLoading
? <Spinner />
: <AppGrid
apps={props.apps.filter((app: App) => app.isPinned)}
totalApps={props.apps.length}
{!config.hideSearch ? (
<SearchBar
setLocalSearch={setLocalSearch}
appSearchResult={appSearchResult}
bookmarkSearchResult={bookmarkSearchResult}
/>
}
) : (
<div></div>
)}
<div className={classes.HomeSpace}></div>
<Header />
<SectionHeadline title='Bookmarks' link='/bookmarks' />
{props.categoriesLoading
? <Spinner />
: <BookmarkGrid
categories={props.categories.filter((category: Category) => category.isPinned)}
totalCategories={props.categories.length}
/>
}
{!isAuthenticated &&
!apps.some((a) => a.isPinned) &&
!categories.some((c) => c.isPinned) ? (
<Message>
Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>,
login and start customizing your new homepage
</Message>
) : (
<></>
)}
<Link to='/settings' className={classes.SettingsButton}>
<Icon icon='mdiCog' color='var(--color-background)' />
{!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? (
<Fragment>
<SectionHeadline title="Applications" link="/applications" />
{appsLoading ? (
<Spinner />
) : (
<AppGrid
apps={
!appSearchResult
? apps.filter(({ isPinned }) => isPinned)
: appSearchResult
}
totalApps={apps.length}
searching={!!localSearch}
/>
)}
<div className={classes.HomeSpace}></div>
</Fragment>
) : (
<></>
)}
{!config.hideCategories &&
(isAuthenticated || categories.some((c) => c.isPinned)) ? (
<Fragment>
<SectionHeadline title="Bookmarks" link="/bookmarks" />
{bookmarksLoading ? (
<Spinner />
) : (
<BookmarkGrid
categories={
!bookmarkSearchResult
? categories.filter(
({ isPinned, bookmarks }) => isPinned && bookmarks.length
)
: bookmarkSearchResult
}
totalCategories={categories.length}
searching={!!localSearch}
fromHomepage={true}
/>
)}
</Fragment>
) : (
<></>
)}
<Link to="/settings" className={classes.SettingsButton}>
<Icon icon="mdiCog" color="var(--color-background)" />
</Link>
</Container>
)
}
const mapStateToProps = (state: GlobalState) => {
return {
appsLoading: state.app.loading,
apps: state.app.apps,
categoriesLoading: state.bookmark.loading,
categories: state.bookmark.categories
}
}
export default connect(mapStateToProps, { getApps, getCategories })(Home);
);
};

View file

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

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

@ -0,0 +1,18 @@
.SearchBar {
width: 100%;
padding: 10px 0;
color: var(--color-primary);
/* font-size: 20px; */
margin-bottom: 20px;
background-color: transparent;
border: none;
border-bottom: 2px solid var(--color-accent);
opacity: 0.5;
transition: all 0.2s;
border-radius: 0px;
}
.SearchBar:focus {
opacity: 1;
outline: none;
}

View file

@ -0,0 +1,132 @@
import { useRef, useEffect, KeyboardEvent } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
// Typescript
import { App, Category } from '../../interfaces';
// CSS
import classes from './SearchBar.module.css';
// Utils
import { searchParser, urlParser, redirectUrl } from '../../utility';
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
interface Props {
setLocalSearch: (query: string) => void;
appSearchResult: App[] | null;
bookmarkSearchResult: Category[] | null;
}
export const SearchBar = (props: Props): JSX.Element => {
const { config, loading } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { createNotification } = bindActionCreators(actionCreators, dispatch);
const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
// Search bar autofocus
useEffect(() => {
if (!loading && !config.disableAutofocus) {
inputRef.current.focus();
}
}, [config]);
// Listen for keyboard events outside of search bar
useEffect(() => {
const keyOutsideFocus = (e: any) => {
const { key } = e as KeyboardEvent;
if (key === 'Escape') {
clearSearch();
} else if (document.activeElement !== inputRef.current) {
if (key === '`') {
inputRef.current.focus();
clearSearch();
}
}
};
window.addEventListener('keyup', keyOutsideFocus);
return () => window.removeEventListener('keyup', keyOutsideFocus);
}, []);
const clearSearch = () => {
inputRef.current.value = '';
setLocalSearch('');
};
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
const {
isLocal,
encodedURL,
primarySearch,
secondarySearch,
isURL,
sameTab,
rawQuery,
} = searchParser(inputRef.current.value);
if (isLocal) {
setLocalSearch(encodedURL);
}
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
if (!primarySearch.prefix) {
// Prefix not found -> emit notification
createNotification({
title: 'Error',
message: 'Prefix not found',
});
} else if (isURL) {
// URL or IP passed -> redirect
const url = urlParser(inputRef.current.value)[1];
redirectUrl(url, sameTab);
} else if (isLocal) {
// Local query -> redirect if at least 1 result found
if (appSearchResult?.length) {
redirectUrl(appSearchResult[0].url, sameTab);
} else if (bookmarkSearchResult?.[0]?.bookmarks?.length) {
redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab);
} else {
// no local results -> search the internet with the default search provider if query is not empty
if (!/^ *$/.test(rawQuery)) {
let template = primarySearch.template;
if (primarySearch.prefix === 'l') {
template = secondarySearch.template;
}
const url = `${template}${encodedURL}`;
redirectUrl(url, sameTab);
}
}
} else {
// Valid query -> redirect to search results
const url = `${primarySearch.template}${encodedURL}`;
redirectUrl(url, sameTab);
}
} else if (e.code === 'Escape') {
clearSearch();
}
};
return (
<div className={classes.SearchContainer}>
<input
ref={inputRef}
type="text"
className={classes.SearchBar}
onKeyUp={(e) => searchHandler(e)}
onDoubleClick={clearSearch}
/>
</div>
);
};

View file

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

View file

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

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

@ -0,0 +1,116 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { Query } from '../../../../interfaces';
import { Button, InputGroup, ModalForm } from '../../../UI';
interface Props {
modalHandler: () => void;
query?: Query;
}
export const QueriesForm = (props: Props): JSX.Element => {
const dispatch = useDispatch();
const { addQuery, updateQuery } = bindActionCreators(
actionCreators,
dispatch
);
const { modalHandler, query } = props;
const [formData, setFormData] = useState<Query>({
name: '',
prefix: '',
template: '',
});
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
};
const formHandler = (e: FormEvent) => {
e.preventDefault();
if (query) {
updateQuery(formData, query.prefix);
} else {
addQuery(formData);
}
// close modal
modalHandler();
// clear form
setFormData({
name: '',
prefix: '',
template: '',
});
};
useEffect(() => {
if (query) {
setFormData(query);
} else {
setFormData({
name: '',
prefix: '',
template: '',
});
}
}, [query]);
return (
<ModalForm modalHandler={modalHandler} formHandler={formHandler}>
<InputGroup>
<label htmlFor="name">Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Google"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="name">Prefix</label>
<input
type="text"
name="prefix"
id="prefix"
placeholder="g"
required
value={formData.prefix}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="name">Query Template</label>
<input
type="text"
name="template"
id="template"
placeholder="https://www.google.com/search?q="
required
value={formData.template}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
</ModalForm>
);
};

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,88 +0,0 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import axios from 'axios';
import { connect } from 'react-redux';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
import { createNotification } from '../../../store/actions';
import { ApiResponse, Config, NewNotification } from '../../../interfaces';
interface FormState {
customTitle: string;
}
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
}
const OtherSettings = (props: ComponentProps): JSX.Element => {
const [formData, setFormData] = useState<FormState>({
customTitle: document.title
})
// get initial config
useEffect(() => {
axios.get<ApiResponse<Config[]>>('/api/config?keys=customTitle')
.then(data => {
let tmpFormData = { ...formData };
data.data.data.forEach((config: Config) => {
let value: string | number = config.value;
if (config.valueType === 'number') {
value = parseFloat(value);
}
tmpFormData = {
...tmpFormData,
[config.key]: value
}
})
setFormData(tmpFormData);
})
.catch(err => console.log(err));
}, [])
const formSubmitHandler = (e: FormEvent) => {
e.preventDefault();
axios.put<ApiResponse<{}>>('/api/config', formData)
.then(() => {
props.createNotification({
title: 'Success',
message: 'Settings updated'
})
})
.catch((err) => console.log(err));
// update local page title
localStorage.setItem('customTitle', formData.customTitle);
document.title = formData.customTitle;
}
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<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>
<Button>Save changes</Button>
</form>
)
}
export default connect(null, { createNotification })(OtherSettings);

View file

@ -1,54 +1,79 @@
import { NavLink, Link, Switch, Route, withRouter } from 'react-router-dom';
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';
// CSS
import classes from './Settings.module.css';
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
import Themer from '../Themer/Themer';
import WeatherSettings from './WeatherSettings/WeatherSettings';
import OtherSettings from './OtherSettings/OtherSettings';
// Components
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, Headline } from '../UI';
// Data
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>}
/>
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
<div className={classes.Settings}>
{/* NAVIGATION MENU */}
<nav className={classes.SettingsNav}>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings'>
Theme
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/weather'>
Weather
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/other'>
Other
</NavLink>
{tabs.map(({ name, dest }: SettingsRoute, idx) => (
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to={dest}
key={idx}
>
{name}
</NavLink>
))}
</nav>
{/* ROUTES */}
<section className={classes.SettingsContent}>
<Switch>
<Route exact path='/settings' component={Themer} />
<Route path='/settings/weather' component={WeatherSettings} />
<Route path='/settings/other' component={OtherSettings} />
<Route exact path="/settings" component={Themer} />
<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>
</div>
</Container>
)
}
export default withRouter(Settings);
);
};

View file

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

View file

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

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