Compare commits

...

30 Commits
v1.0 ... v1.4

Author SHA1 Message Date
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
pawelmalak
cf44f45fde Merge pull request #9 from pawelmalak/qol-improvements
Added favicon and changed page title. Changed message when there are …
2021-06-09 01:04:26 +02:00
unknown
30ed700521 Added favicon and changed page title. Changed message when there are apps/bookmarks created but they are not pinned to homescreen. Added functionality to set custom page title. 2021-06-09 01:01:32 +02:00
88 changed files with 1738 additions and 556 deletions

0
._env
View File

2
.env Normal file
View File

@@ -0,0 +1,2 @@
PORT=5005
NODE_ENV=development

3
.gitignore vendored
View File

@@ -1,3 +1,2 @@
node_modules/
data/
.env
data/

View File

@@ -2,9 +2,9 @@ FROM node:14-alpine
WORKDIR /app
COPY package*.json .
COPY package*.json ./
RUN npm install --only=production
RUN npm install --production
COPY . .

24
Dockerfile.multiarch Normal file
View File

@@ -0,0 +1,24 @@
FROM node:14-alpine
WORKDIR /app
COPY package*.json ./
RUN apk --no-cache --virtual build-dependencies add python make g++ \
&& npm install --production
COPY . .
RUN mkdir -p ./public ./data \
&& cd ./client \
&& npm run build \
&& cd .. \
&& mv ./client/build/* ./public \
&& rm -rf ./client \
&& apk del build-dependencies
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.

View File

@@ -1,16 +1,21 @@
# Flame
[![JS Badge](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black)](https://shields.io/)
[![TS Badge](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://shields.io/)
[![Node Badge](https://img.shields.io/badge/Node.js-43853D?style=for-the-badge&logo=node.js&logoColor=white)](https://shields.io/)
[![React Badge](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)](https://shields.io/)
![Homescreen screenshot](./github/_home.png)
## Description
Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui)
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own appliaction hub in no time - no file editing necessary.
## Technology
- Backend
- Node.js + Express
- Sequelize ORM + SQLite
- Frontend
- React
- React
- Redux
- TypeScript
- Deployment
@@ -18,6 +23,7 @@ Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI]
## Development
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
cd flame
@@ -28,13 +34,23 @@ npm run dev-init
npm run dev
```
## Deployment with Docker
## Building Docker images
```sh
# build image
# build image for amd64 only
docker build -t flame .
# build multiarch image for amd64, armv7 and arm64
# building failed multiple times with 2GB memory usage limit so you might want to increase it
docker buildx build \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-f Dockerfile.multiarch \
-t flame:multiarch .
```
## Deployment with Docker
```sh
# run container
docker run -p 5005:5005 -v <host_dir>:/app/data flame
docker run -p 5005:5005 -v /path/to/data:/app/data flame
```
## Functionality
@@ -55,4 +71,22 @@ docker run -p 5005:5005 -v <host_dir>:/app/data flame
- Themes
- Customize your page by choosing from 12 color themes
![Homescreen screenshot](./github/_themes.png)
![Homescreen screenshot](./github/_themes.png)
## Usage
### Supported URL formats for applications and bookmarks
#### Rules
- URL starts with `http://`
- Format: `http://www.domain.com`, `http://domain.com`
- Redirect: `{dest}`
- URL starts with `https://`
- Format: `https://www.domain.com`, `https://domain.com`
- Redirect: `https://{dest}`
- URL without protocol
- Format: `www.domain.com`, `domain.com`, `sub.domain.com`, `local`, `ip`, `ip:port`
- Redirect: `http://{dest}`
## Support
If you want to support development of Flame and my upcoming self-hosted and open source projects you can use the following link:
[![PayPal Badge](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.com/paypalme/pawelmalak)

View File

@@ -5,11 +5,11 @@ class Socket {
this.webSocketServer = new WebSocket.Server({ server })
this.webSocketServer.on('listening', () => {
console.log('socket listen');
console.log('Socket: listen');
})
this.webSocketServer.on('connection', (webSocketClient) => {
console.log('new connection');
// console.log('Socket: new connection');
})
}

1
client/.env Normal file
View File

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

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

175
client/package-lock.json generated
View File

@@ -2304,6 +2304,14 @@
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
"integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA=="
},
"@types/http-proxy": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz",
"integrity": "sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ==",
"requires": {
"@types/node": "*"
}
},
"@types/istanbul-lib-coverage": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
@@ -2389,6 +2397,14 @@
"csstype": "^3.0.2"
}
},
"@types/react-beautiful-dnd": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz",
"integrity": "sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==",
"requires": {
"@types/react": "*"
}
},
"@types/react-dom": {
"version": "17.0.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
@@ -4606,6 +4622,14 @@
"postcss": "^7.0.5"
}
},
"css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"requires": {
"tiny-invariant": "^1.0.6"
}
},
"css-color-names": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
@@ -7449,110 +7473,21 @@
}
},
"http-proxy-middleware": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz",
"integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.0.tgz",
"integrity": "sha512-S+RN5njuyvYV760aiVKnyuTXqUMcSIvYOsHA891DOVQyrdZOwaXtBHpt9FUVPEDAsOvsPArZp6VXQLs44yvkow==",
"requires": {
"http-proxy": "^1.17.0",
"is-glob": "^4.0.0",
"lodash": "^4.17.11",
"micromatch": "^3.1.10"
"@types/http-proxy": "^1.17.5",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"dependencies": {
"braces": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
"integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
"requires": {
"arr-flatten": "^1.1.0",
"array-unique": "^0.3.2",
"extend-shallow": "^2.0.1",
"fill-range": "^4.0.0",
"isobject": "^3.0.1",
"repeat-element": "^1.1.2",
"snapdragon": "^0.8.1",
"snapdragon-node": "^2.0.1",
"split-string": "^3.0.2",
"to-regex": "^3.0.1"
},
"dependencies": {
"extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"requires": {
"is-extendable": "^0.1.0"
}
}
}
},
"fill-range": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
"integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=",
"requires": {
"extend-shallow": "^2.0.1",
"is-number": "^3.0.0",
"repeat-string": "^1.6.1",
"to-regex-range": "^2.1.0"
},
"dependencies": {
"extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"requires": {
"is-extendable": "^0.1.0"
}
}
}
},
"is-number": {
"is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
"integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
"requires": {
"kind-of": "^3.0.2"
},
"dependencies": {
"kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
"requires": {
"is-buffer": "^1.1.5"
}
}
}
},
"micromatch": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
"integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
"requires": {
"arr-diff": "^4.0.0",
"array-unique": "^0.3.2",
"braces": "^2.3.1",
"define-property": "^2.0.2",
"extend-shallow": "^3.0.2",
"extglob": "^2.0.4",
"fragment-cache": "^0.2.1",
"kind-of": "^6.0.2",
"nanomatch": "^1.2.9",
"object.pick": "^1.3.0",
"regex-not": "^1.0.0",
"snapdragon": "^0.8.1",
"to-regex": "^3.0.2"
}
},
"to-regex-range": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
"integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=",
"requires": {
"is-number": "^3.0.0",
"repeat-string": "^1.6.1"
}
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA=="
}
}
},
@@ -10013,6 +9948,11 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
},
"memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"memory-fs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
@@ -12381,6 +12321,11 @@
"performance-now": "^2.1.0"
}
},
"raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -12443,6 +12388,20 @@
"whatwg-fetch": "^3.4.1"
}
},
"react-beautiful-dnd": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz",
"integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==",
"requires": {
"@babel/runtime": "^7.9.2",
"css-box-model": "^1.2.0",
"memoize-one": "^5.1.1",
"raf-schd": "^4.0.2",
"react-redux": "^7.2.0",
"redux": "^4.0.4",
"use-memo-one": "^1.1.1"
}
},
"react-dev-utils": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
@@ -15158,6 +15117,11 @@
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
},
"use-memo-one": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz",
"integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ=="
},
"util": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
@@ -16033,6 +15997,17 @@
}
}
},
"http-proxy-middleware": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz",
"integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==",
"requires": {
"http-proxy": "^1.17.0",
"is-glob": "^4.0.0",
"lodash": "^4.17.11",
"micromatch": "^3.1.10"
}
},
"import-local": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz",

View File

@@ -11,11 +11,14 @@
"@types/jest": "^26.0.23",
"@types/node": "^12.20.12",
"@types/react": "^17.0.5",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.3",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"axios": "^0.21.1",
"http-proxy-middleware": "^2.0.0",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0",
@@ -50,6 +53,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:5005"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -10,36 +10,15 @@
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet">
<title>React App</title>
<title>Flame</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -1,23 +1,31 @@
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { setTheme } from './store/actions';
import { getConfig, setTheme } from './store/actions';
// Redux
import store from './store/store';
import { store } from './store/store';
import { Provider } from 'react-redux';
import classes from './App.module.css';
// Utils
import { checkVersion } from './utility';
// Routes
import Home from './components/Home/Home';
import Apps from './components/Apps/Apps';
import Settings from './components/Settings/Settings';
import Bookmarks from './components/Bookmarks/Bookmarks';
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
// Get config pairs from database
store.dispatch<any>(getConfig());
// Set theme
if (localStorage.theme) {
store.dispatch<any>(setTheme(localStorage.theme));
}
// Check for updates
checkVersion();
const App = (): JSX.Element => {
return (
<Provider store={store}>

View File

@@ -27,4 +27,16 @@
font-weight: 400;
font-size: 0.8em;
opacity: 1;
}
@media (min-width: 500px) {
.AppCard {
padding: 2px;
border-radius: 4px;
transition: all 0.10s;
}
.AppCard:hover {
background-color: rgba(0,0,0,0.2);
}
}

View File

@@ -1,7 +1,6 @@
import { Link } from 'react-router-dom';
import classes from './AppCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser, urlParser } from '../../../utility';
import { App } from '../../../interfaces';
@@ -11,28 +10,21 @@ interface ComponentProps {
}
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}`;
return parsedName;
}
const redirectHandler = (url: string): void => {
window.open(url);
}
const [displayUrl, redirectUrl] = urlParser(props.app.url);
return (
<a href={`http://${props.app.url}`} target='blank' className={classes.AppCard}>
<a
href={redirectUrl}
target='_blank'
rel='noreferrer'
className={classes.AppCard}
>
<div className={classes.AppCardIcon}>
<Icon icon={iconParser(props.app.icon)} />
</div>
<div className={classes.AppCardDetails}>
<h5>{props.app.name}</h5>
<span>{props.app.url}</span>
<span>{displayUrl}</span>
</div>
</a>
)

View File

@@ -98,7 +98,15 @@ const AppForm = (props: ComponentProps): JSX.Element => {
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Use URL without protocol</span>
<span>
<a
href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks'
target='_blank'
rel='noreferrer'
>
{' '}Check supported URL formats
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor='icon'>App Icon</label>

View File

@@ -6,6 +6,7 @@ import AppCard from '../AppCard/AppCard';
interface ComponentProps {
apps: App[];
totalApps?: number;
}
const AppGrid = (props: ComponentProps): JSX.Element => {
@@ -23,9 +24,15 @@ const AppGrid = (props: ComponentProps): JSX.Element => {
</div>
)
} else {
apps = (
<p className={classes.AppsMessage}>You don't have any applications. You can add a new one from <Link to='/applications'>/application</Link> menu</p>
);
if (props.totalApps) {
apps = (
<p className={classes.AppsMessage}>There are no pinned applications. You can pin them from the <Link to='/applications'>/applications</Link> menu</p>
);
} else {
apps = (
<p className={classes.AppsMessage}>You don't have any applications. You can add a new one from <Link to='/applications'>/applications</Link> menu</p>
);
}
}
return apps;

View File

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

View File

@@ -1,20 +1,52 @@
import { KeyboardEvent } from 'react';
import { connect } from 'react-redux';
import { App, GlobalState } from '../../../interfaces';
import { pinApp, deleteApp } from '../../../store/actions';
import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { Link } from 'react-router-dom';
// Redux
import { connect } from 'react-redux';
import { pinApp, deleteApp, reorderApps, updateConfig, createNotification } from '../../../store/actions';
// Typescript
import { App, GlobalState, NewNotification } from '../../../interfaces';
// CSS
import classes from './AppTable.module.css';
// UI
import Icon from '../../UI/Icons/Icon/Icon';
import Table from '../../UI/Table/Table';
// Utils
import { searchConfig } from '../../../utility';
interface ComponentProps {
apps: App[];
pinApp: (app: App) => void;
deleteApp: (id: number) => void;
updateAppHandler: (app: App) => void;
reorderApps: (apps: App[]) => void;
updateConfig: (formData: any) => void;
createNotification: (notification: NewNotification) => void;
}
const AppTable = (props: ComponentProps): JSX.Element => {
const [localApps, setLocalApps] = useState<App[]>([]);
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
// Copy apps array
useEffect(() => {
setLocalApps([...props.apps]);
}, [props.apps])
// Check ordering
useEffect(() => {
const order = searchConfig('useOrdering', '');
if (order === 'orderId') {
setIsCustomOrder(true);
}
}, [])
const deleteAppHandler = (app: App): void => {
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
@@ -23,55 +55,111 @@ const AppTable = (props: ComponentProps): JSX.Element => {
}
}
// Support keyboard navigation for actions
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
if (e.key === 'Enter') {
handler(app);
}
}
const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) {
props.createNotification({
title: 'Error',
message: 'Custom order is disabled'
})
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);
props.reorderApps(tmpApps);
}
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>
<div className={classes.Message}>
{isCustomOrder
? <p>You can drag and drop single rows to reorder application</p>
: <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
}
</div>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId='apps'>
{(provided) => (
<Table headers={[
'Name',
'URL',
'Icon',
'Actions'
]}
innerRef={provided.innerRef}>
{localApps.map((app: App, index): JSX.Element => {
return (
<Draggable key={app.id} draggableId={app.id.toString()} index={index}>
{(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' }}>{app.name}</td>
<td style={{ width:'200px' }}>{app.url}</td>
<td style={{ width:'200px' }}>{app.icon}</td>
{!snapshot.isDragging && (
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteAppHandler(app)}
onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
tabIndex={0}>
<Icon icon='mdiDelete' />
</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>
)
}}
</Draggable>
)
})}
</Table>
)}
</Droppable>
</DragDropContext>
</Fragment>
)
}
@@ -81,4 +169,12 @@ const mapStateToProps = (state: GlobalState) => {
}
}
export default connect(mapStateToProps, { pinApp, deleteApp })(AppTable);
const actions = {
pinApp,
deleteApp,
reorderApps,
updateConfig,
createNotification
}
export default connect(mapStateToProps, actions)(AppTable);

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
// Redux
@@ -30,6 +30,12 @@ interface ComponentProps {
}
const Apps = (props: ComponentProps): JSX.Element => {
const {
getApps,
apps,
loading
} = props;
const [modalIsOpen, setModalIsOpen] = useState(false);
const [isInEdit, setIsInEdit] = useState(false);
const [isInUpdate, setIsInUpdate] = useState(false);
@@ -38,16 +44,17 @@ const Apps = (props: ComponentProps): JSX.Element => {
url: 'string',
icon: 'string',
isPinned: false,
orderId: 0,
id: 0,
createdAt: new Date(),
updatedAt: new Date()
})
useEffect(() => {
if (props.apps.length === 0) {
props.getApps();
if (apps.length === 0) {
getApps();
}
}, [props.getApps]);
}, [getApps]);
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
@@ -93,10 +100,10 @@ const Apps = (props: ComponentProps): JSX.Element => {
</div>
<div className={classes.Apps}>
{props.loading
{loading
? <Spinner />
: (!isInEdit
? <AppGrid apps={props.apps} />
? <AppGrid apps={apps} />
: <AppTable updateAppHandler={toggleUpdate} />)
}
</div>

View File

@@ -18,9 +18,18 @@
.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;
}

View File

@@ -1,6 +1,9 @@
import { Bookmark, Category } from '../../../interfaces';
import classes from './BookmarkCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser, urlParser } from '../../../utility';
interface ComponentProps {
category: Category;
}
@@ -10,14 +13,24 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
<div className={classes.BookmarkCard}>
<h3>{props.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>
))}
{props.category.bookmarks.map((bookmark: Bookmark) => {
const redirectUrl = urlParser(bookmark.url)[1];
return (
<a
href={redirectUrl}
target='_blank'
rel='noreferrer'
key={`bookmark-${bookmark.id}`}>
{bookmark.icon && (
<div className={classes.BookmarkIcon}>
<Icon icon={iconParser(bookmark.icon)} />
</div>
)}
{bookmark.name}
</a>
)
})}
</div>
</div>
)

View File

@@ -29,9 +29,11 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
const [formData, setFormData] = useState<NewBookmark>({
name: '',
url: '',
categoryId: -1
categoryId: -1,
icon: ''
})
// Load category data if provided for editing
useEffect(() => {
if (props.category) {
setCategoryName({ name: props.category.name });
@@ -40,18 +42,21 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
}
}, [props.category])
// Load bookmark data if provided for editing
useEffect(() => {
if (props.bookmark) {
setFormData({
name: props.bookmark.name,
url: props.bookmark.url,
categoryId: props.bookmark.categoryId
categoryId: props.bookmark.categoryId,
icon: props.bookmark.icon
})
} else {
setFormData({
name: '',
url: '',
categoryId: -1
categoryId: -1,
icon: ''
})
}
}, [props.bookmark])
@@ -79,7 +84,8 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
setFormData({
name: '',
url: '',
categoryId: formData.categoryId
categoryId: formData.categoryId,
icon: ''
})
}
} else {
@@ -94,7 +100,8 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
setFormData({
name: '',
url: '',
categoryId: -1
categoryId: -1,
icon: ''
})
}
@@ -177,6 +184,15 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks'
target='_blank'
rel='noreferrer'
>
{' '}Check supported URL formats
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor='categoryId'>Bookmark Category</label>
@@ -200,6 +216,25 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
})}
</select>
</InputGroup>
<InputGroup>
<label htmlFor='icon'>Bookmark Icon (optional)</label>
<input
type='text'
name='icon'
id='icon'
placeholder='book-open-outline'
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI.
<a
href='https://materialdesignicons.com/'
target='blank'>
{' '}Click here for reference
</a>
</span>
</InputGroup>
</Fragment>
)
}

View File

@@ -2,12 +2,13 @@ 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';
interface ComponentProps {
categories: Category[];
totalCategories?: number;
}
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
@@ -20,9 +21,15 @@ const BookmarkGrid = (props: ComponentProps): JSX.Element => {
</div>
);
} else {
bookmarks = (
<p className={classes.BookmarksMessage}>You don't have any bookmarks. You can add a new one from <Link to='/bookmarks'>/bookmarks</Link> menu</p>
);
if (props.totalCategories) {
bookmarks = (
<p className={classes.BookmarksMessage}>There are no pinned categories. You can pin them from the <Link to='/bookmarks'>/bookmarks</Link> menu</p>
);
} else {
bookmarks = (
<p className={classes.BookmarksMessage}>You don't have any bookmarks. You can add a new one from <Link to='/bookmarks'>/bookmarks</Link> menu</p>
);
}
}
return bookmarks;

View File

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

View File

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

View File

@@ -28,6 +28,12 @@ export enum ContentType {
}
const Bookmarks = (props: ComponentProps): JSX.Element => {
const {
getCategories,
categories,
loading
} = props;
const [modalIsOpen, setModalIsOpen] = useState(false);
const [formContentType, setFormContentType] = useState(ContentType.category);
const [isInEdit, setIsInEdit] = useState(false);
@@ -37,6 +43,7 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
name: '',
id: -1,
isPinned: false,
orderId: 0,
bookmarks: [],
createdAt: new Date(),
updatedAt: new Date()
@@ -45,16 +52,17 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
name: '',
url: '',
categoryId: -1,
icon: '',
id: -1,
createdAt: new Date(),
updatedAt: new Date()
})
useEffect(() => {
if (props.categories.length === 0) {
props.getCategories();
if (categories.length === 0) {
getCategories();
}
}, [props.getCategories])
}, [getCategories])
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
@@ -131,13 +139,13 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
/>
</div>
{props.loading
{loading
? <Spinner />
: (!isInEdit
? <BookmarkGrid categories={props.categories} />
? <BookmarkGrid categories={categories} />
: <BookmarkTable
contentType={tableContentType}
categories={props.categories}
categories={categories}
updateHandler={goToUpdateMode}
/>
)

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
// Redux
@@ -23,6 +23,13 @@ import AppGrid from '../Apps/AppGrid/AppGrid';
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget';
// Functions
import { greeter } from './functions/greeter';
import { dateTime } from './functions/dateTime';
// Utils
import { searchConfig } from '../../utility';
interface ComponentProps {
getApps: Function;
getCategories: Function;
@@ -33,63 +40,85 @@ interface ComponentProps {
}
const Home = (props: ComponentProps): JSX.Element => {
const {
getApps,
apps,
appsLoading,
getCategories,
categories,
categoriesLoading
} = props;
const [header, setHeader] = useState({
dateTime: dateTime(),
greeting: greeter()
})
// Load applications
useEffect(() => {
if (props.apps.length === 0) {
props.getApps();
if (apps.length === 0) {
getApps();
}
}, [props.getApps]);
}, [getApps]);
// Load bookmark categories
useEffect(() => {
if (props.categories.length === 0) {
props.getCategories();
if (categories.length === 0) {
getCategories();
}
}, [props.getCategories]);
}, [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'];
// Refresh greeter and time
useEffect(() => {
let interval: any;
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;
}
// Start interval only when hideHeader is false
if (searchConfig('hideHeader', 0) !== 1) {
interval = setInterval(() => {
setHeader({
dateTime: dateTime(),
greeting: greeter()
})
}, 1000);
}
return () => clearInterval(interval);
}, [])
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>
{searchConfig('hideHeader', 0) !== 1
? (
<header className={classes.Header}>
<p>{header.dateTime}</p>
<Link to='/settings' className={classes.SettingsLink}>Go to Settings</Link>
<span className={classes.HeaderMain}>
<h1>{header.greeting}</h1>
<WeatherWidget />
</span>
</header>
)
: <div></div>
}
<SectionHeadline title='Applications' link='/applications' />
{props.appsLoading
{appsLoading
? <Spinner />
: <AppGrid apps={props.apps.filter((app: App) => app.isPinned)} />
: <AppGrid
apps={apps.filter((app: App) => app.isPinned)}
totalApps={apps.length}
/>
}
<div className={classes.HomeSpace}></div>
<SectionHeadline title='Bookmarks' link='/bookmarks' />
{props.categoriesLoading
{categoriesLoading
? <Spinner />
: <BookmarkGrid categories={props.categories.filter((category: Category) => category.isPinned)} />
: <BookmarkGrid
categories={categories.filter((category: Category) => category.isPinned)}
totalCategories={categories.length}
/>
}
<Link to='/settings' className={classes.SettingsButton}>

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
.AppVersion {
color: var(--color-primary);
margin-bottom: 15px;
}
.AppVersion a {
color: var(--color-accent);
}

View File

@@ -0,0 +1,25 @@
import { Fragment } from 'react';
import classes from './AppDetails.module.css';
import Button from '../../UI/Buttons/Button/Button';
import { checkVersion } from '../../../utility';
const AppDetails = (): JSX.Element => {
return (
<Fragment>
<p className={classes.AppVersion}>
<a
href='https://github.com/pawelmalak/flame'
target='_blank'
rel='noreferrer'>
Flame
</a>
{' '}
version {process.env.REACT_APP_VERSION}
</p>
<Button click={() => checkVersion(true)}>Check for updates</Button>
</Fragment>
)
}
export default AppDetails;

View File

@@ -0,0 +1,155 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
// Redux
import { connect } from 'react-redux';
import { createNotification, updateConfig, sortApps, sortCategories } from '../../../store/actions';
// Typescript
import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
// Utils
import { searchConfig } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: SettingsForm) => void;
sortApps: () => void;
sortCategories: () => void;
loading: boolean;
}
const OtherSettings = (props: ComponentProps): JSX.Element => {
// Initial state
const [formData, setFormData] = useState<SettingsForm>({
customTitle: document.title,
pinAppsByDefault: 1,
pinCategoriesByDefault: 1,
hideHeader: 0,
useOrdering: 'createdAt'
})
// Get config
useEffect(() => {
setFormData({
customTitle: searchConfig('customTitle', 'Flame'),
pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
hideHeader: searchConfig('hideHeader', 0),
useOrdering: searchConfig('useOrdering', 'createdAt')
})
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await props.updateConfig(formData);
// Update local page title
document.title = formData.customTitle;
// Sort apps and categories with new settings
props.sortApps();
props.sortCategories();
}
// Input handler
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
let value: string | number = e.target.value;
if (isNumber) {
value = parseFloat(value);
}
setFormData({
...formData,
[e.target.name]: 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>
<InputGroup>
<label htmlFor='pinAppsByDefault'>Pin new applications by default</label>
<select
id='pinAppsByDefault'
name='pinAppsByDefault'
value={formData.pinAppsByDefault}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor='pinCategoriesByDefault'>Pin new categories by default</label>
<select
id='pinCategoriesByDefault'
name='pinCategoriesByDefault'
value={formData.pinCategoriesByDefault}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor='hideHeader'>Hide greeting and date</label>
<select
id='hideHeader'
name='hideHeader'
value={formData.hideHeader}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<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>
<Button>Save changes</Button>
</form>
)
}
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading
}
}
const actions = {
createNotification,
updateConfig,
sortApps,
sortCategories
}
export default connect(mapStateToProps, actions)(OtherSettings);

View File

@@ -1,11 +1,14 @@
import { NavLink, Link, Switch, Route, withRouter } from 'react-router-dom';
import { NavLink, Link, Switch, Route } from 'react-router-dom';
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';
import AppDetails from './AppDetails/AppDetails';
const Settings = (): JSX.Element => {
return (
@@ -30,11 +33,27 @@ const Settings = (): JSX.Element => {
to='/settings/weather'>
Weather
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/other'>
Other
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/app'>
App
</NavLink>
</nav>
<section className={classes.SettingsContent}>
<Switch>
<Route exact path='/settings' component={Themer} />
<Route path='/settings/weather' component={WeatherSettings} />
<Route path='/settings/other' component={OtherSettings} />
<Route path='/settings/app' component={AppDetails} />
</Switch>
</section>
</div>
@@ -42,4 +61,4 @@ const Settings = (): JSX.Element => {
)
}
export default withRouter(Settings);
export default Settings;

View File

@@ -1,31 +1,77 @@
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
import { connect } from 'react-redux';
import axios from 'axios';
import { ApiResponse, Config, NewNotification, Weather } from '../../../interfaces';
// Redux
import { connect } from 'react-redux';
import { createNotification, updateConfig } from '../../../store/actions';
// Typescript
import { ApiResponse, GlobalState, NewNotification, Weather, WeatherForm } from '../../../interfaces';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
import { createNotification } from '../../../store/actions';
interface FormState {
WEATHER_API_KEY: string;
lat: number;
long: number;
isCelsius: number;
}
// Utils
import { searchConfig } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: WeatherForm) => void;
loading: boolean;
}
const WeatherSettings = (props: ComponentProps): JSX.Element => {
const [formData, setFormData] = useState<FormState>({
// Initial state
const [formData, setFormData] = useState<WeatherForm>({
WEATHER_API_KEY: '',
lat: 0,
long: 0,
isCelsius: 1
})
// Get config
useEffect(() => {
setFormData({
WEATHER_API_KEY: searchConfig('WEATHER_API_KEY', ''),
lat: searchConfig('lat', 0),
long: searchConfig('long', 0),
isCelsius: searchConfig('isCelsius', 1)
})
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Check for api key input
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
props.createNotification({
title: 'Warning',
message: 'API key is missing. Weather Module will NOT work'
})
}
// Save settings
await props.updateConfig(formData);
// Update weather
axios.get<ApiResponse<Weather>>('/api/weather/update')
.then(() => {
props.createNotification({
title: 'Success',
message: 'Weather updated'
})
})
.catch((err) => {
props.createNotification({
title: 'Error',
message: err.response.data.error
})
});
}
// Input handler
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
let value: string | number = e.target.value;
@@ -39,63 +85,10 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
})
}
useEffect(() => {
axios.get<ApiResponse<Config[]>>('/api/config?keys=WEATHER_API_KEY,lat,long,isCelsius')
.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'
})
// Update weather with new settings
axios.get<ApiResponse<Weather>>('/api/weather/update')
.then(() => {
props.createNotification({
title: 'Success',
message: 'Weather updated'
})
})
.catch((err) => {
props.createNotification({
title: 'Error',
message: err.response.data.error
})
});
})
.catch(err => console.log(err));
// set localStorage
localStorage.setItem('isCelsius', JSON.stringify(parseInt(`${formData.isCelsius}`) === 1))
}
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<InputGroup>
<label htmlFor='WEATHER_API_KEY'>API Key</label>
<label htmlFor='WEATHER_API_KEY'>API key</label>
<input
type='text'
id='WEATHER_API_KEY'
@@ -111,10 +104,11 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
target='blank'>
{' '}Weather API
</a>
. Key is required for weather module to work.
</span>
</InputGroup>
<InputGroup>
<label htmlFor='lat'>Location Latitude</label>
<label htmlFor='lat'>Location latitude</label>
<input
type='number'
id='lat'
@@ -122,6 +116,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
placeholder='52.22'
value={formData.lat}
onChange={(e) => inputChangeHandler(e, true)}
step='any'
lang='en-150'
/>
<span>
You can use
@@ -133,7 +129,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
</span>
</InputGroup>
<InputGroup>
<label htmlFor='long'>Location Longitude</label>
<label htmlFor='long'>Location longitude</label>
<input
type='number'
id='long'
@@ -141,10 +137,12 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
placeholder='21.01'
value={formData.long}
onChange={(e) => inputChangeHandler(e, true)}
step='any'
lang='en-150'
/>
</InputGroup>
<InputGroup>
<label htmlFor='isCelsius'>Temperature Unit</label>
<label htmlFor='isCelsius'>Temperature unit</label>
<select
id='isCelsius'
name='isCelsius'
@@ -160,4 +158,10 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
)
}
export default connect(null, { createNotification })(WeatherSettings);
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading
}
}
export default connect(mapStateToProps, { createNotification, updateConfig })(WeatherSettings);

View File

@@ -6,8 +6,7 @@
border-radius: 4px;
}
.Button:hover,
.Button:focus {
.Button:hover {
cursor: pointer;
background-color: var(--color-accent);
color: var(--color-background);

View File

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

View File

@@ -12,8 +12,8 @@ interface ComponentProps {
const WeatherIcon = (props: ComponentProps): JSX.Element => {
const icon = props.isDay
? (new IconMapping).mapIcon(props.weatherStatusCode, TimeOfDay.day)
: (new IconMapping).mapIcon(props.weatherStatusCode, TimeOfDay.night);
? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)
: new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.night);
useEffect(() => {
const delay = setTimeout(() => {
@@ -25,7 +25,7 @@ const WeatherIcon = (props: ComponentProps): JSX.Element => {
return () => {
clearTimeout(delay);
}
}, [props.weatherStatusCode]);
}, [props.weatherStatusCode, icon, props.theme.colors.accent]);
return <canvas id={`weather-icon`} width='50' height='50'></canvas>
}

View File

@@ -1,4 +1,4 @@
import { MouseEvent, useRef, useEffect } from 'react';
import { MouseEvent, useRef } from 'react';
import classes from './Modal.module.css';

View File

@@ -8,15 +8,17 @@
text-align: left;
font-size: 16px;
color: var(--color-primary);
table-layout: fixed;
}
.Table th,
.Table td {
padding: 10px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Head */
.Table th {
--header-radius: 4px;
background-color: var(--color-primary);
@@ -34,8 +36,6 @@
}
/* Body */
.Table td {
/* opacity: 0.5; */
transition: all 0.2s;
}

View File

@@ -3,11 +3,12 @@ import classes from './Table.module.css';
interface ComponentProps {
children: JSX.Element | JSX.Element[];
headers: string[];
innerRef?: any;
}
const Table = (props: ComponentProps): JSX.Element => {
return (
<div className={classes.TableContainer}>
<div className={classes.TableContainer} ref={props.innerRef}>
<table className={classes.Table}>
<thead className={classes.TableHead}>
<tr>

View File

@@ -1,12 +1,27 @@
import { useState, useEffect, Fragment } from 'react';
import { Weather, ApiResponse, Config } from '../../../interfaces';
import axios from 'axios';
import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
// Redux
import { connect } from 'react-redux';
// Typescript
import { Weather, ApiResponse, Config, GlobalState } from '../../../interfaces';
// CSS
import classes from './WeatherWidget.module.css';
const WeatherWidget = (): JSX.Element => {
// UI
import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
// Utils
import { searchConfig } from '../../../utility';
interface ComponentProps {
configLoading: boolean;
config: Config[];
}
const WeatherWidget = (props: ComponentProps): JSX.Element => {
const [weather, setWeather] = useState<Weather>({
externalLastUpdate: '',
tempC: 0,
@@ -20,11 +35,9 @@ const WeatherWidget = (): JSX.Element => {
updatedAt: new Date()
});
const [isLoading, setIsLoading] = useState(true);
const [isCelsius, setIsCelsius] = useState(true);
// Initial request to get data
useEffect(() => {
// get weather
axios.get<ApiResponse<Weather[]>>('/api/weather')
.then(data => {
const weatherData = data.data.data[0];
@@ -34,23 +47,13 @@ const WeatherWidget = (): JSX.Element => {
setIsLoading(false);
})
.catch(err => console.log(err));
// get config
if (!localStorage.isCelsius) {
axios.get<ApiResponse<Config>>('/api/config/isCelsius')
.then((data) => {
setIsCelsius(parseInt(data.data.data.value) === 1);
localStorage.setItem('isCelsius', JSON.stringify(isCelsius));
})
.catch((err) => console.log(err));
} else {
setIsCelsius(JSON.parse(localStorage.isCelsius));
}
}, []);
// Open socket for data updates
useEffect(() => {
const webSocketClient = new WebSocket('ws://localhost:5005');
const socketProtocol = document.location.protocol === 'http:' ? 'ws:' : 'wss:';
const socketAddress = `${socketProtocol}//${window.location.host}/socket`;
const webSocketClient = new WebSocket(socketAddress);
webSocketClient.onmessage = (e) => {
const data = JSON.parse(e.data);
@@ -65,9 +68,8 @@ const WeatherWidget = (): JSX.Element => {
return (
<div className={classes.WeatherWidget}>
{isLoading
? 'loading'
: (weather.id > 0 &&
{(isLoading || props.configLoading || searchConfig('WEATHER_API_KEY', '')) &&
(weather.id > 0 &&
(<Fragment>
<div className={classes.WeatherIcon}>
<WeatherIcon
@@ -76,7 +78,7 @@ const WeatherWidget = (): JSX.Element => {
/>
</div>
<div className={classes.WeatherDetails}>
{isCelsius
{searchConfig('isCelsius', true)
? <span>{weather.tempC}°C</span>
: <span>{weather.tempF}°F</span>
}
@@ -89,4 +91,11 @@ const WeatherWidget = (): JSX.Element => {
)
}
export default WeatherWidget;
const mapStateToProps = (state: GlobalState) => {
return {
configLoading: state.config.loading,
config: state.config.config
}
}
export default connect(mapStateToProps)(WeatherWidget);

View File

@@ -5,6 +5,7 @@ export interface App extends Model {
url: string;
icon: string;
isPinned: boolean;
orderId: number;
}
export interface NewApp {

View File

@@ -4,10 +4,12 @@ export interface Bookmark extends Model {
name: string;
url: string;
categoryId: number;
icon: string;
}
export interface NewBookmark {
name: string;
url: string;
categoryId: number;
icon: string;
}

View File

@@ -3,6 +3,7 @@ import { Model, Bookmark } from '.';
export interface Category extends Model {
name: string;
isPinned: boolean;
orderId: number;
bookmarks: Bookmark[];
}

View File

@@ -0,0 +1,14 @@
export interface WeatherForm {
WEATHER_API_KEY: string;
lat: number;
long: number;
isCelsius: number;
}
export interface SettingsForm {
customTitle: string;
pinAppsByDefault: number;
pinCategoriesByDefault: number;
hideHeader: number;
useOrdering: string;
}

View File

@@ -2,10 +2,12 @@ import { State as AppState } from '../store/reducers/app';
import { State as ThemeState } from '../store/reducers/theme';
import { State as BookmarkState } from '../store/reducers/bookmark';
import { State as NotificationState } from '../store/reducers/notification';
import { State as ConfigState } from '../store/reducers/config';
export interface GlobalState {
theme: ThemeState;
app: AppState;
bookmark: BookmarkState;
notification: NotificationState;
config: ConfigState;
}

View File

@@ -6,4 +6,5 @@ export * from './Weather';
export * from './Bookmark';
export * from './Category';
export * from './Notification';
export * from './Config';
export * from './Config';
export * from './Forms';

15
client/src/setupProxy.js Normal file
View File

@@ -0,0 +1,15 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function (app) {
const apiProxy = createProxyMiddleware('/api', {
target: 'http://localhost:5005'
})
const wsProxy = createProxyMiddleware('/socket', {
target: 'http://localhost:5005',
ws: true
})
app.use(apiProxy);
app.use(wsProxy);
};

View File

@@ -7,19 +7,26 @@ import {
AddAppAction,
DeleteAppAction,
UpdateAppAction,
ReorderAppsAction,
SortAppsAction,
// Categories
GetCategoriesAction,
AddCategoryAction,
PinCategoryAction,
DeleteCategoryAction,
UpdateCategoryAction,
SortCategoriesAction,
ReorderCategoriesAction,
// Bookmarks
AddBookmarkAction,
DeleteBookmarkAction,
UpdateBookmarkAction,
// Notifications
CreateNotificationAction,
ClearNotificationAction
ClearNotificationAction,
// Config
GetConfigAction,
UpdateConfigAction
} from './';
export enum ActionTypes {
@@ -34,6 +41,8 @@ export enum ActionTypes {
addAppSuccess = 'ADD_APP_SUCCESS',
deleteApp = 'DELETE_APP',
updateApp = 'UPDATE_APP',
reorderApps = 'REORDER_APPS',
sortApps = 'SORT_APPS',
// Categories
getCategories = 'GET_CATEGORIES',
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
@@ -42,13 +51,18 @@ export enum ActionTypes {
pinCategory = 'PIN_CATEGORY',
deleteCategory = 'DELETE_CATEGORY',
updateCategory = 'UPDATE_CATEGORY',
sortCategories = 'SORT_CATEGORIES',
reorderCategories = 'REORDER_CATEGORIES',
// Bookmarks
addBookmark = 'ADD_BOOKMARK',
deleteBookmark = 'DELETE_BOOKMARK',
updateBookmark = 'UPDATE_BOOKMARK',
// Notifications
createNotification = 'CREATE_NOTIFICATION',
clearNotification = 'CLEAR_NOTIFICATION'
clearNotification = 'CLEAR_NOTIFICATION',
// Config
getConfig = 'GET_CONFIG',
updateConfig = 'UPDATE_CONFIG'
}
export type Action =
@@ -60,16 +74,23 @@ export type Action =
AddAppAction |
DeleteAppAction |
UpdateAppAction |
ReorderAppsAction |
SortAppsAction |
// Categories
GetCategoriesAction<any> |
AddCategoryAction |
PinCategoryAction |
DeleteCategoryAction |
UpdateCategoryAction |
SortCategoriesAction |
ReorderCategoriesAction |
// Bookmarks
AddBookmarkAction |
DeleteBookmarkAction |
UpdateBookmarkAction |
// Notifications
CreateNotificationAction |
ClearNotificationAction;
ClearNotificationAction |
// Config
GetConfigAction |
UpdateConfigAction;

View File

@@ -1,7 +1,7 @@
import axios from 'axios';
import { Dispatch } from 'redux';
import { ActionTypes } from './actionTypes';
import { App, ApiResponse, NewApp } from '../../interfaces';
import { App, ApiResponse, NewApp, Config } from '../../interfaces';
import { CreateNotificationAction } from './notification';
export interface GetAppsAction<T> {
@@ -23,10 +23,7 @@ export const getApps = () => async (dispatch: Dispatch) => {
payload: res.data.data
})
} catch (err) {
dispatch<GetAppsAction<string>>({
type: ActionTypes.getAppsError,
payload: err.data.data
})
console.log(err);
}
}
@@ -76,10 +73,13 @@ export const addApp = (formData: NewApp) => async (dispatch: Dispatch) => {
}
})
dispatch<AddAppAction>({
await dispatch<AddAppAction>({
type: ActionTypes.addAppSuccess,
payload: res.data.data
})
// Sort apps
dispatch<any>(sortApps())
} catch (err) {
console.log(err);
}
@@ -92,7 +92,7 @@ export interface DeleteAppAction {
export const deleteApp = (id: number) => async (dispatch: Dispatch) => {
try {
const res = await axios.delete<ApiResponse<{}>>(`/api/apps/${id}`);
await axios.delete<ApiResponse<{}>>(`/api/apps/${id}`);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
@@ -128,10 +128,63 @@ export const updateApp = (id: number, formData: NewApp) => async (dispatch: Disp
}
})
dispatch<UpdateAppAction>({
await dispatch<UpdateAppAction>({
type: ActionTypes.updateApp,
payload: res.data.data
})
// Sort apps
dispatch<any>(sortApps())
} catch (err) {
console.log(err);
}
}
export interface ReorderAppsAction {
type: ActionTypes.reorderApps;
payload: App[]
}
interface ReorderQuery {
apps: {
id: number;
orderId: number;
}[]
}
export const reorderApps = (apps: App[]) => async (dispatch: Dispatch) => {
try {
const updateQuery: ReorderQuery = { apps: [] }
apps.forEach((app, index) => updateQuery.apps.push({
id: app.id,
orderId: index + 1
}))
await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery);
dispatch<ReorderAppsAction>({
type: ActionTypes.reorderApps,
payload: apps
})
} catch (err) {
console.log(err);
}
}
export interface SortAppsAction {
type: ActionTypes.sortApps;
payload: string;
}
export const sortApps = () => async (dispatch: Dispatch) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config/useOrdering');
dispatch<SortAppsAction>({
type: ActionTypes.sortApps,
payload: res.data.data.value
})
} catch (err) {
console.log(err);
}

View File

@@ -1,7 +1,7 @@
import axios from 'axios';
import { Dispatch } from 'redux';
import { ActionTypes } from './actionTypes';
import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark } from '../../interfaces';
import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark, Config } from '../../interfaces';
import { CreateNotificationAction } from './notification';
/**
@@ -54,6 +54,8 @@ export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch)
type: ActionTypes.addCategory,
payload: res.data.data
})
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
@@ -130,7 +132,7 @@ export interface DeleteCategoryAction {
export const deleteCategory = (id: number) => async (dispatch: Dispatch) => {
try {
const res = await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`);
await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
@@ -173,6 +175,8 @@ export const updateCategory = (id: number, formData: NewCategory) => async (disp
type: ActionTypes.updateCategory,
payload: res.data.data
})
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
@@ -191,7 +195,7 @@ export interface DeleteBookmarkAction {
export const deleteBookmark = (bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => {
try {
const res = await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`);
await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
@@ -261,4 +265,60 @@ export const updateBookmark = (bookmarkId: number, formData: NewBookmark, previo
} catch (err) {
console.log(err);
}
}
/**
* SORT CATEGORIES
*/
export interface SortCategoriesAction {
type: ActionTypes.sortCategories;
payload: string;
}
export const sortCategories = () => async (dispatch: Dispatch) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config/useOrdering');
dispatch<SortCategoriesAction>({
type: ActionTypes.sortCategories,
payload: res.data.data.value
})
} catch (err) {
console.log(err);
}
}
/**
* REORDER CATEGORIES
*/
export interface ReorderCategoriesAction {
type: ActionTypes.reorderCategories;
payload: Category[];
}
interface ReorderQuery {
categories: {
id: number;
orderId: number;
}[]
}
export const reorderCategories = (categories: Category[]) => async (dispatch: Dispatch) => {
try {
const updateQuery: ReorderQuery = { categories: [] }
categories.forEach((category, index) => updateQuery.categories.push({
id: category.id,
orderId: index + 1
}))
await axios.put<ApiResponse<{}>>('/api/categories/0/reorder', updateQuery);
dispatch<ReorderCategoriesAction>({
type: ActionTypes.reorderCategories,
payload: categories
})
} catch (err) {
console.log(err);
}
}

View File

@@ -0,0 +1,52 @@
import axios from 'axios';
import { Dispatch } from 'redux';
import { ActionTypes } from './actionTypes';
import { Config, ApiResponse } from '../../interfaces';
import { CreateNotificationAction } from './notification';
import { searchConfig } from '../../utility';
export interface GetConfigAction {
type: ActionTypes.getConfig;
payload: Config[];
}
export const getConfig = () => async (dispatch: Dispatch) => {
try {
const res = await axios.get<ApiResponse<Config[]>>('/api/config');
dispatch<GetConfigAction>({
type: ActionTypes.getConfig,
payload: res.data.data
})
// Set custom page title if set
document.title = searchConfig('customTitle', 'Flame');
} catch (err) {
console.log(err)
}
}
export interface UpdateConfigAction {
type: ActionTypes.updateConfig;
payload: Config[];
}
export const updateConfig = (formData: any) => async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<Config[]>>('/api/config', formData);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: 'Settings updated'
}
})
dispatch<UpdateConfigAction>({
type: ActionTypes.updateConfig,
payload: res.data.data
})
} catch (err) {
console.log(err);
}
}

View File

@@ -2,4 +2,5 @@ export * from './theme';
export * from './app';
export * from './actionTypes';
export * from './bookmark';
export * from './notification';
export * from './notification';
export * from './config';

View File

@@ -1,5 +1,6 @@
import { ActionTypes, Action } from '../actions';
import { App } from '../../interfaces/App';
import { sortData } from '../../utility';
export interface State {
loading: boolean;
@@ -52,11 +53,9 @@ const pinApp = (state: State, action: Action): State => {
}
const addAppSuccess = (state: State, action: Action): State => {
const tmpApps = [...state.apps, action.payload];
return {
...state,
apps: tmpApps
apps: [...state.apps, action.payload]
}
}
@@ -85,6 +84,22 @@ const updateApp = (state: State, action: Action): State => {
}
}
const reorderApps = (state: State, action: Action): State => {
return {
...state,
apps: action.payload
}
}
const sortApps = (state: State, action: Action): State => {
const sortedApps = sortData<App>(state.apps, action.payload);
return {
...state,
apps: sortedApps
}
}
const appReducer = (state = initialState, action: Action) => {
switch (action.type) {
case ActionTypes.getApps: return getApps(state, action);
@@ -94,6 +109,8 @@ const appReducer = (state = initialState, action: Action) => {
case ActionTypes.addAppSuccess: return addAppSuccess(state, action);
case ActionTypes.deleteApp: return deleteApp(state, action);
case ActionTypes.updateApp: return updateApp(state, action);
case ActionTypes.reorderApps: return reorderApps(state, action);
case ActionTypes.sortApps: return sortApps(state, action);
default: return state;
}
}

View File

@@ -1,5 +1,6 @@
import { ActionTypes, Action } from '../actions';
import { Category, Bookmark } from '../../interfaces';
import { sortData } from '../../utility';
export interface State {
loading: boolean;
@@ -141,6 +142,22 @@ const updateBookmark = (state: State, action: Action): State => {
}
}
const sortCategories = (state: State, action: Action): State => {
const sortedCategories = sortData<Category>(state.categories, action.payload);
return {
...state,
categories: sortedCategories
}
}
const reorderCategories = (state: State, action: Action): State => {
return {
...state,
categories: action.payload
}
}
const bookmarkReducer = (state = initialState, action: Action) => {
switch (action.type) {
case ActionTypes.getCategories: return getCategories(state, action);
@@ -152,6 +169,8 @@ const bookmarkReducer = (state = initialState, action: Action) => {
case ActionTypes.updateCategory: return updateCategory(state, action);
case ActionTypes.deleteBookmark: return deleteBookmark(state, action);
case ActionTypes.updateBookmark: return updateBookmark(state, action);
case ActionTypes.sortCategories: return sortCategories(state, action);
case ActionTypes.reorderCategories: return reorderCategories(state, action);
default: return state;
}
}

View File

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

View File

@@ -6,12 +6,14 @@ import themeReducer from './theme';
import appReducer from './app';
import bookmarkReducer from './bookmark';
import notificationReducer from './notification';
import configReducer from './config';
const rootReducer = combineReducers<GlobalState>({
theme: themeReducer,
app: appReducer,
bookmark: bookmarkReducer,
notification: notificationReducer
notification: notificationReducer,
config: configReducer
})
export default rootReducer;

View File

@@ -4,6 +4,4 @@ import thunk from 'redux-thunk';
import rootReducer from './reducers';
const initialState = {};
const store = createStore(rootReducer, initialState, composeWithDevTools(applyMiddleware(thunk)));
export default store;
export const store = createStore(rootReducer, initialState, composeWithDevTools(applyMiddleware(thunk)));

View File

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

View File

@@ -0,0 +1,14 @@
/**
* Parse Material Desgin icon name to be used with mdi/js
* @param mdiName Dash separated icon name from MDI, e.g. alert-box-outline
* @returns Parsed icon name to be used with mdi/js, e.g mdiAlertBoxOutline
*/
export const iconParser = (mdiName: string): string => {
let parsedName = mdiName
.split('-')
.map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
.join('');
parsedName = `mdi${parsedName}`;
return parsedName;
}

View File

@@ -0,0 +1,5 @@
export * from './iconParser';
export * from './urlParser';
export * from './searchConfig';
export * from './checkVersion';
export * from './sortData';

View File

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

View File

@@ -0,0 +1,29 @@
interface Data {
name: string;
orderId: number;
createdAt: Date;
}
export const sortData = <T extends Data>(array: T[], field: string): T[] => {
const sortedData = array.slice();
if (field === 'name') {
sortedData.sort((a: T, b: T) => {
return a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
})
} else if (field === 'orderId') {
sortedData.sort((a: T, b: T) => {
if (a.orderId < b.orderId) { return -1 }
if (a.orderId > b.orderId) { return 1 }
return 0;
})
} else {
sortedData.sort((a: T, b: T) => {
if (a.createdAt < b.createdAt) { return -1 }
if (a.createdAt > b.createdAt) { return 1 }
return 0;
})
}
return sortedData;
}

View File

@@ -0,0 +1,20 @@
export const urlParser = (url: string): string[] => {
let parsedUrl: string;
let displayUrl: string;
if (/https?:\/\//.test(url)) {
// Url starts with http[s]:// -> leave it as it is
parsedUrl = url;
} else {
// No protocol -> apply http:// prefix
parsedUrl = `http://${url}`;
}
// Create simplified url to display as text
displayUrl = url
.replace(/https?:\/\//, '')
.replace('www.', '')
.replace(/\/$/, '');
return [displayUrl, parsedUrl]
}

View File

@@ -1,12 +1,30 @@
const asyncWrapper = require('../middleware/asyncWrapper');
const ErrorResponse = require('../utils/ErrorResponse');
const App = require('../models/App');
const Config = require('../models/Config');
const { Sequelize } = require('sequelize');
// @desc Create new app
// @route POST /api/apps
// @access Public
exports.createApp = asyncWrapper(async (req, res, next) => {
const app = await App.create(req.body);
// Get config from database
const pinApps = await Config.findOne({
where: { key: 'pinAppsByDefault' }
});
let app;
if (pinApps) {
if (parseInt(pinApps.value)) {
app = await App.create({
...req.body,
isPinned: true
})
} else {
app = await App.create(req.body);
}
}
res.status(201).json({
success: true,
@@ -18,10 +36,24 @@ exports.createApp = asyncWrapper(async (req, res, next) => {
// @route GET /api/apps
// @access Public
exports.getApps = asyncWrapper(async (req, res, next) => {
const apps = await App.findAll({
order: [['name', 'ASC']]
// Get config from database
const useOrdering = await Config.findOne({
where: { key: 'useOrdering' }
});
const orderType = useOrdering ? useOrdering.value : 'createdAt';
let apps;
if (orderType == 'name') {
apps = await App.findAll({
order: [[ Sequelize.fn('lower', Sequelize.col('name')), 'ASC' ]]
});
} else {
apps = await App.findAll({
order: [[ orderType, 'ASC' ]]
});
}
res.status(200).json({
success: true,
data: apps
@@ -74,6 +106,22 @@ exports.deleteApp = asyncWrapper(async (req, res, next) => {
where: { id: req.params.id }
})
res.status(200).json({
success: true,
data: {}
})
})
// @desc Reorder apps
// @route PUT /api/apps/0/reorder
// @access Public
exports.reorderApps = asyncWrapper(async (req, res, next) => {
req.body.apps.forEach(async ({ id, orderId }) => {
await App.update({ orderId }, {
where: { id }
})
})
res.status(200).json({
success: true,
data: {}

View File

@@ -1,6 +1,7 @@
const asyncWrapper = require('../middleware/asyncWrapper');
const ErrorResponse = require('../utils/ErrorResponse');
const Bookmark = require('../models/Bookmark');
const { Sequelize } = require('sequelize');
// @desc Create new bookmark
// @route POST /api/bookmarks
@@ -19,7 +20,7 @@ exports.createBookmark = asyncWrapper(async (req, res, next) => {
// @access Public
exports.getBookmarks = asyncWrapper(async (req, res, next) => {
const bookmarks = await Bookmark.findAll({
order: [['name', 'ASC']]
order: [[ Sequelize.fn('lower', Sequelize.col('name')), 'ASC' ]]
});
res.status(200).json({

View File

@@ -2,12 +2,30 @@ const asyncWrapper = require('../middleware/asyncWrapper');
const ErrorResponse = require('../utils/ErrorResponse');
const Category = require('../models/Category');
const Bookmark = require('../models/Bookmark');
const Config = require('../models/Config');
const { Sequelize } = require('sequelize')
// @desc Create new category
// @route POST /api/categories
// @access Public
exports.createCategory = asyncWrapper(async (req, res, next) => {
const category = await Category.create(req.body);
// Get config from database
const pinCategories = await Config.findOne({
where: { key: 'pinCategoriesByDefault' }
});
let category;
if (pinCategories) {
if (parseInt(pinCategories.value)) {
category = await Category.create({
...req.body,
isPinned: true
})
} else {
category = await Category.create(req.body);
}
}
res.status(201).json({
success: true,
@@ -19,14 +37,32 @@ exports.createCategory = asyncWrapper(async (req, res, next) => {
// @route GET /api/categories
// @access Public
exports.getCategories = asyncWrapper(async (req, res, next) => {
const categories = await Category.findAll({
include: [{
model: Bookmark,
as: 'bookmarks'
}],
order: [['name', 'ASC']]
// Get config from database
const useOrdering = await Config.findOne({
where: { key: 'useOrdering' }
});
const orderType = useOrdering ? useOrdering.value : 'createdAt';
let categories;
if (orderType == 'name') {
categories = await Category.findAll({
include: [{
model: Bookmark,
as: 'bookmarks'
}],
order: [[ Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC' ]]
});
} else {
categories = await Category.findAll({
include: [{
model: Bookmark,
as: 'bookmarks'
}],
order: [[ orderType, 'ASC' ]]
});
}
res.status(200).json({
success: true,
data: categories
@@ -101,6 +137,22 @@ exports.deleteCategory = asyncWrapper(async (req, res, next) => {
where: { id: req.params.id }
})
res.status(200).json({
success: true,
data: {}
})
})
// @desc Reorder categories
// @route PUT /api/categories/0/reorder
// @access Public
exports.reorderCategories = asyncWrapper(async (req, res, next) => {
req.body.categories.forEach(async ({ id, orderId }) => {
await Category.update({ orderId }, {
where: { id }
})
})
res.status(200).json({
success: true,
data: {}

View File

@@ -96,9 +96,11 @@ exports.updateValues = asyncWrapper(async (req, res, next) => {
})
})
const config = await Config.findAll();
res.status(200).send({
success: true,
data: {}
data: config
})
})

7
db.js
View File

@@ -8,13 +8,10 @@ const sequelize = new Sequelize({
const connectDB = async () => {
try {
await sequelize.authenticate({ logging: false });
await sequelize.authenticate();
console.log('Connected to database');
await sequelize.sync({
// alter: true,
logging: false
});
await sequelize.sync({ alter: true });
console.log('All models were synced');
} catch (error) {
console.error('Unable to connect to the database:', error);

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
version: "3"
services:
flame:
image: pawelmalak/flame
container_name: flame
volumes:
- /path/to/data:/app/data
ports:
- 5005:5005
restart: unless-stopped

View File

@@ -18,6 +18,11 @@ const App = sequelize.define('App', {
isPinned: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
orderId: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null
}
}, {
tableName: 'apps'

View File

@@ -13,6 +13,10 @@ const Bookmark = sequelize.define('Bookmark', {
categoryId: {
type: DataTypes.INTEGER,
allowNull: false
},
icon: {
type: DataTypes.STRING,
defaultValue: ''
}
}, {
tableName: 'bookmarks'

View File

@@ -9,6 +9,11 @@ const Category = sequelize.define('Category', {
isPinned: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
orderId: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null
}
}, {
tableName: 'categories'

6
package-lock.json generated
View File

@@ -2794,9 +2794,9 @@
}
},
"ws": {
"version": "7.4.5",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz",
"integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g=="
"version": "7.4.6",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
"integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A=="
},
"xdg-basedir": {
"version": "4.0.0",

View File

@@ -24,7 +24,7 @@
"node-schedule": "^2.0.0",
"sequelize": "^6.6.2",
"sqlite3": "^5.0.2",
"ws": "^7.4.5"
"ws": "^7.4.6"
},
"devDependencies": {
"nodemon": "^2.0.7"

View File

@@ -6,7 +6,8 @@ const {
getApps,
getApp,
updateApp,
deleteApp
deleteApp,
reorderApps
} = require('../controllers/apps');
router
@@ -20,4 +21,8 @@ router
.put(updateApp)
.delete(deleteApp);
router
.route('/0/reorder')
.put(reorderApps);
module.exports = router;

View File

@@ -6,7 +6,8 @@ const {
getCategories,
getCategory,
updateCategory,
deleteCategory
deleteCategory,
reorderCategories
} = require('../controllers/category');
router
@@ -20,4 +21,8 @@ router
.put(updateCategory)
.delete(deleteCategory);
router
.route('/0/reorder')
.put(reorderCategories);
module.exports = router;

22
utils/clearWeatherData.js Normal file
View File

@@ -0,0 +1,22 @@
const { Op } = require('sequelize');
const Weather = require('../models/Weather');
const clearWeatherData = async () => {
const weather = await Weather.findOne({
order: [[ 'createdAt', 'DESC' ]]
});
if (weather) {
await Weather.destroy({
where: {
id: {
[Op.lt]: weather.id
}
}
})
}
console.log('Old weather data was deleted');
}
module.exports = clearWeatherData;

View File

@@ -1,16 +1,13 @@
const { Op } = require('sequelize');
const Config = require('../models/Config');
const { config } = require('./initialConfig.json');
const initConfig = async () => {
// Config keys
const keys = ['WEATHER_API_KEY', 'lat', 'long', 'isCelsius'];
const values = ['', 0, 0, true];
// Get config values
const configPairs = await Config.findAll({
where: {
key: {
[Op.or]: keys
[Op.or]: config.map(pair => pair.key)
}
}
})
@@ -19,12 +16,12 @@ const initConfig = async () => {
const configKeys = configPairs.map((pair) => pair.key);
// Create missing pairs
keys.forEach(async (key, idx) => {
config.forEach(async ({ key, value}) => {
if (!configKeys.includes(key)) {
await Config.create({
key,
value: values[idx],
valueType: typeof values[idx]
value,
valueType: typeof value
})
}
})

40
utils/initialConfig.json Normal file
View File

@@ -0,0 +1,40 @@
{
"config": [
{
"key": "WEATHER_API_KEY",
"value": ""
},
{
"key": "lat",
"value": 0
},
{
"key": "long",
"value": 0
},
{
"key": "isCelsius",
"value": true
},
{
"key": "customTitle",
"value": "Flame"
},
{
"key": "pinAppsByDefault",
"value": true
},
{
"key": "pinCategoriesByDefault",
"value": true
},
{
"key": "hideHeader",
"value": false
},
{
"key": "useOrdering",
"value": "createdAt"
}
]
}

View File

@@ -1,5 +1,6 @@
const schedule = require('node-schedule');
const getExternalWeather = require('./getExternalWeather');
const clearWeatherData = require('./clearWeatherData');
const Sockets = require('../Sockets');
// Update weather data every 15 minutes
@@ -14,6 +15,6 @@ const weatherJob = schedule.scheduleJob('updateWeather', '0 */15 * * * *', async
})
// Clear old weather data every 4 hours
const weatherCleanerJob = schedule.scheduleJob('clearWeather', '0 0 */4 * * *', async () => {
console.log('clean')
const weatherCleanerJob = schedule.scheduleJob('clearWeather', '0 5 */4 * * *', async () => {
clearWeatherData();
})