Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aec00982ba | ||
|
|
8026533a06 | ||
|
|
550e1e155b | ||
|
|
12974ab01b | ||
|
|
6c067bee31 | ||
|
|
db4a10171e | ||
|
|
472cfd6610 | ||
|
|
e3ed429da1 | ||
|
|
5ae4d6e7c4 | ||
|
|
4c3255107c | ||
|
|
41a3f5dae3 | ||
|
|
4ca3b509cf | ||
|
|
28680bec1a | ||
|
|
ae3141e37b | ||
|
|
5b900872af | ||
|
|
754dc3a7b9 | ||
|
|
8974fb3b49 | ||
|
|
ce173f2c42 | ||
|
|
9a1ec76ffd | ||
|
|
a9be4df157 | ||
|
|
e884c84aa8 | ||
|
|
ad5e7646c1 | ||
|
|
ff1d11f512 | ||
|
|
5e7cb72b82 | ||
|
|
f137498e7e | ||
|
|
d257fbf9a3 | ||
|
|
a5504e6e80 | ||
|
|
5968663be4 | ||
|
|
66cc59c48e | ||
|
|
f5f735372a |
@@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
github
|
github
|
||||||
|
public
|
||||||
6
.gitignore
vendored
@@ -1,3 +1,3 @@
|
|||||||
node_modules/
|
node_modules
|
||||||
data/
|
data
|
||||||
.env
|
public
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
FROM node:14-alpine
|
FROM node:14-alpine
|
||||||
|
|
||||||
|
RUN apk update && apk add --no-cache nano
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@@ -10,6 +12,7 @@ COPY . .
|
|||||||
|
|
||||||
RUN mkdir -p ./public ./data \
|
RUN mkdir -p ./public ./data \
|
||||||
&& cd ./client \
|
&& cd ./client \
|
||||||
|
&& npm install --production \
|
||||||
&& npm run build \
|
&& npm run build \
|
||||||
&& cd .. \
|
&& cd .. \
|
||||||
&& mv ./client/build/* ./public \
|
&& mv ./client/build/* ./public \
|
||||||
@@ -19,4 +22,4 @@ EXPOSE 5005
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
27
Dockerfile.multiarch
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM node:14-alpine
|
||||||
|
|
||||||
|
RUN apk update && apk add --no-cache nano
|
||||||
|
|
||||||
|
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 install --production \
|
||||||
|
&& 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
@@ -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.
|
||||||
87
README.md
@@ -1,16 +1,21 @@
|
|||||||
# Flame
|
# Flame
|
||||||
|
|
||||||
|
[](https://shields.io/)
|
||||||
|
[](https://shields.io/)
|
||||||
|
[](https://shields.io/)
|
||||||
|
[](https://shields.io/)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Description
|
## 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
|
## Technology
|
||||||
- Backend
|
- Backend
|
||||||
- Node.js + Express
|
- Node.js + Express
|
||||||
- Sequelize ORM + SQLite
|
- Sequelize ORM + SQLite
|
||||||
- Frontend
|
- Frontend
|
||||||
- React
|
- React
|
||||||
- Redux
|
- Redux
|
||||||
- TypeScript
|
- TypeScript
|
||||||
- Deployment
|
- Deployment
|
||||||
@@ -18,6 +23,7 @@ Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI]
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
```sh
|
```sh
|
||||||
|
# clone repository
|
||||||
git clone https://github.com/pawelmalak/flame
|
git clone https://github.com/pawelmalak/flame
|
||||||
cd flame
|
cd flame
|
||||||
|
|
||||||
@@ -28,31 +34,94 @@ npm run dev-init
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment with Docker
|
## Installation
|
||||||
|
|
||||||
|
### With Docker (recommended)
|
||||||
|
#### Building images
|
||||||
```sh
|
```sh
|
||||||
# build image
|
# build image for amd64 only
|
||||||
docker build -t flame .
|
docker build -t flame .
|
||||||
|
|
||||||
# run container
|
# build multiarch image for amd64, armv7 and arm64
|
||||||
docker run -p 5005:5005 -v <host_dir>:/app/data flame
|
# 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
|
||||||
|
```sh
|
||||||
|
# run container
|
||||||
|
docker run -p 5005:5005 -v /path/to/data:/app/data flame
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Docker
|
||||||
|
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
|
||||||
|
|
||||||
## Functionality
|
## Functionality
|
||||||
- Applications
|
- Applications
|
||||||
- Create, update and delete applications using GUI
|
- Create, update, delete and organize applications using GUI
|
||||||
- Pin your favourite apps to homescreen
|
- Pin your favourite apps to homescreen
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- Bookmarks
|
- Bookmarks
|
||||||
- Create, update and delete bookmarks and categories using GUI
|
- Create, update, delete and organize bookmarks and categories using GUI
|
||||||
- Pin your favourite categories to homescreen
|
- Pin your favourite categories to homescreen
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- Weather
|
- Weather
|
||||||
- Get current temperature, cloud coverage and weather status with animated icons
|
- Get current temperature, cloud coverage and weather status with animated icons
|
||||||
|
|
||||||
- Themes
|
- Themes
|
||||||
- Customize your page by choosing from 12 color themes
|
- Customize your page by choosing from 12 color themes
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Usage
|
||||||
|
### Search bar
|
||||||
|
> While opening links, module will follow `Open all links in the same tab` setting
|
||||||
|
#### Supported search engines
|
||||||
|
| Name | Prefix | Search URL |
|
||||||
|
|------------|--------|-------------------------------------|
|
||||||
|
| Disroot | /ds | http://search.disroot.org/search?q= |
|
||||||
|
| DuckDuckGo | /d | https://duckduckgo.com/?q= |
|
||||||
|
| Google | /g | https://www.google.com/search?q= |
|
||||||
|
|
||||||
|
#### Supported services
|
||||||
|
| Name | Prefix | Search URL |
|
||||||
|
|--------------------|--------|-----------------------------------------------|
|
||||||
|
| IMDb | /im | https://www.imdb.com/find?q= |
|
||||||
|
| Reddit | /r | -https://www.reddit.com/search?q= |
|
||||||
|
| The Movie Database | /mv | https://www.themoviedb.org/search?query= |
|
||||||
|
| Youtube | /yt | https://www.youtube.com/results?search_query= |
|
||||||
|
|
||||||
|
### Setting up weather module
|
||||||
|
1. Obtain API Key from [Weather API](https://www.weatherapi.com/pricing.aspx).
|
||||||
|
> Free plan allows for 1M calls per month. Flame is making less then 3K API calls per month.
|
||||||
|
2. Get lat/long for your location. You can get them from [latlong.net](https://www.latlong.net/convert-address-to-lat-long.html).
|
||||||
|
3. Enter and save data. Weather widget will now update and should be visible on Home page.
|
||||||
|
|
||||||
|
### Supported URL formats for applications and bookmarks
|
||||||
|
#### Rules
|
||||||
|
- URL starts with `http://`
|
||||||
|
- Format: `http://www.domain.com`, `http://domain.com`
|
||||||
|
- Redirect: `{dest}`
|
||||||
|
- URL starts with `https://`
|
||||||
|
- Format: `https://www.domain.com`, `https://domain.com`
|
||||||
|
- Redirect: `https://{dest}`
|
||||||
|
- URL without protocol
|
||||||
|
- Format: `www.domain.com`, `domain.com`, `sub.domain.com`, `local`, `ip`, `ip:port`
|
||||||
|
- Redirect: `http://{dest}`
|
||||||
|
|
||||||
|
### Custom CSS
|
||||||
|
> This is an experimental feature. Its behaviour might change in the future.
|
||||||
|
>
|
||||||
|
Follow instructions from wiki: [Custom CSS](https://github.com/pawelmalak/flame/wiki/Custom-CSS)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
If you want to support development of Flame and my upcoming self-hosted and open source projects you can use the following link:
|
||||||
|
|
||||||
|
[](https://www.paypal.com/paypalme/pawelmalak)
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
|
const Logger = require('./utils/Logger');
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
class Socket {
|
class Socket {
|
||||||
constructor(server) {
|
constructor(server) {
|
||||||
this.webSocketServer = new WebSocket.Server({ server })
|
this.webSocketServer = new WebSocket.Server({ server })
|
||||||
|
|
||||||
this.webSocketServer.on('listening', () => {
|
this.webSocketServer.on('listening', () => {
|
||||||
console.log('Socket: listen');
|
logger.log('Socket: listen');
|
||||||
})
|
})
|
||||||
|
|
||||||
this.webSocketServer.on('connection', (webSocketClient) => {
|
this.webSocketServer.on('connection', (webSocketClient) => {
|
||||||
console.log('Socket: new connection');
|
// console.log('Socket: new connection');
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
8
api.js
@@ -1,15 +1,17 @@
|
|||||||
const path = require('path');
|
const { join } = require('path');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const errorHandler = require('./middleware/errorHandler');
|
const errorHandler = require('./middleware/errorHandler');
|
||||||
|
|
||||||
const api = express();
|
const api = express();
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
api.use(express.static(path.join(__dirname, 'public')));
|
api.use(express.static(join(__dirname, 'public')));
|
||||||
|
api.use('/uploads', express.static(join(__dirname, 'data/uploads')));
|
||||||
api.get(/^\/(?!api)/, (req, res) => {
|
api.get(/^\/(?!api)/, (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public/index.html'));
|
res.sendFile(join(__dirname, 'public/index.html'));
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// Body parser
|
// Body parser
|
||||||
api.use(express.json());
|
api.use(express.json());
|
||||||
|
|
||||||
|
|||||||
1
client/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
REACT_APP_VERSION=1.5.0
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Getting Started with Create React App
|
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
|
||||||
|
|
||||||
## Available Scripts
|
|
||||||
|
|
||||||
In the project directory, you can run:
|
|
||||||
|
|
||||||
### `npm start`
|
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
|
||||||
|
|
||||||
The page will reload if you make edits.\
|
|
||||||
You will also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `npm test`
|
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
|
||||||
|
|
||||||
### `npm run build`
|
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
|
||||||
Your app is ready to be deployed!
|
|
||||||
|
|
||||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
|
||||||
|
|
||||||
### `npm run eject`
|
|
||||||
|
|
||||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
|
||||||
|
|
||||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
|
||||||
|
|
||||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
|
||||||
|
|
||||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
|
||||||
|
|
||||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
|
||||||
45
client/package-lock.json
generated
@@ -2397,6 +2397,14 @@
|
|||||||
"csstype": "^3.0.2"
|
"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": {
|
"@types/react-dom": {
|
||||||
"version": "17.0.3",
|
"version": "17.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
|
||||||
@@ -4614,6 +4622,14 @@
|
|||||||
"postcss": "^7.0.5"
|
"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": {
|
"css-color-names": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
|
||||||
@@ -9932,6 +9948,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
|
"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": {
|
"memory-fs": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
||||||
@@ -12300,6 +12321,11 @@
|
|||||||
"performance-now": "^2.1.0"
|
"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": {
|
"randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -12362,6 +12388,20 @@
|
|||||||
"whatwg-fetch": "^3.4.1"
|
"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": {
|
"react-dev-utils": {
|
||||||
"version": "11.0.4",
|
"version": "11.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
|
||||||
@@ -15077,6 +15117,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
||||||
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
|
"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": {
|
"util": {
|
||||||
"version": "0.11.1",
|
"version": "0.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
|
||||||
|
|||||||
@@ -11,12 +11,14 @@
|
|||||||
"@types/jest": "^26.0.23",
|
"@types/jest": "^26.0.23",
|
||||||
"@types/node": "^12.20.12",
|
"@types/node": "^12.20.12",
|
||||||
"@types/react": "^17.0.5",
|
"@types/react": "^17.0.5",
|
||||||
|
"@types/react-beautiful-dnd": "^13.0.0",
|
||||||
"@types/react-dom": "^17.0.3",
|
"@types/react-dom": "^17.0.3",
|
||||||
"@types/react-redux": "^7.1.16",
|
"@types/react-redux": "^7.1.16",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"http-proxy-middleware": "^2.0.0",
|
"http-proxy-middleware": "^2.0.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-redux": "^7.2.4",
|
"react-redux": "^7.2.4",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
|
|||||||
@@ -4,15 +4,10 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="description" content="Flame - self-hosted startpage for your server" />
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Web site created using create-react-app"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="%PUBLIC_URL%/flame.css">
|
||||||
<title>Flame</title>
|
<title>Flame</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -21,4 +16,4 @@
|
|||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
Disallow: /
|
||||||
@@ -1,26 +1,30 @@
|
|||||||
import { BrowserRouter, Route, Switch } from 'react-router-dom';
|
import { BrowserRouter, Route, Switch } from 'react-router-dom';
|
||||||
import { setTheme } from './store/actions';
|
import { getConfig, setTheme } from './store/actions';
|
||||||
|
|
||||||
// Redux
|
// Redux
|
||||||
import store from './store/store';
|
import { store } from './store/store';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import classes from './App.module.css';
|
// Utils
|
||||||
|
import { checkVersion } from './utility';
|
||||||
|
|
||||||
|
// Routes
|
||||||
import Home from './components/Home/Home';
|
import Home from './components/Home/Home';
|
||||||
import Apps from './components/Apps/Apps';
|
import Apps from './components/Apps/Apps';
|
||||||
import Settings from './components/Settings/Settings';
|
import Settings from './components/Settings/Settings';
|
||||||
import Bookmarks from './components/Bookmarks/Bookmarks';
|
import Bookmarks from './components/Bookmarks/Bookmarks';
|
||||||
|
|
||||||
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
|
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
|
||||||
|
|
||||||
|
// Get config pairs from database
|
||||||
|
store.dispatch<any>(getConfig());
|
||||||
|
|
||||||
|
// Set theme
|
||||||
if (localStorage.theme) {
|
if (localStorage.theme) {
|
||||||
store.dispatch<any>(setTheme(localStorage.theme));
|
store.dispatch<any>(setTheme(localStorage.theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localStorage.customTitle) {
|
// Check for updates
|
||||||
document.title = localStorage.customTitle;
|
checkVersion();
|
||||||
}
|
|
||||||
|
|
||||||
const App = (): JSX.Element => {
|
const App = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -39,4 +39,12 @@
|
|||||||
.AppCard:hover {
|
.AppCard:hover {
|
||||||
background-color: rgba(0,0,0,0.2);
|
background-color: rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CustomIcon {
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import classes from './AppCard.module.css';
|
import classes from './AppCard.module.css';
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
import { iconParser } from '../../../utility/iconParser';
|
import { iconParser, urlParser } from '../../../utility';
|
||||||
|
|
||||||
import { App } from '../../../interfaces';
|
import { App } from '../../../interfaces';
|
||||||
|
import { searchConfig } from '../../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
app: App;
|
app: App;
|
||||||
@@ -12,18 +11,28 @@ interface ComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AppCard = (props: ComponentProps): JSX.Element => {
|
const AppCard = (props: ComponentProps): JSX.Element => {
|
||||||
const redirectHandler = (url: string): void => {
|
const [displayUrl, redirectUrl] = urlParser(props.app.url);
|
||||||
window.open(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={`http://${props.app.url}`} target='_blank' className={classes.AppCard}>
|
<a
|
||||||
|
href={redirectUrl}
|
||||||
|
target={searchConfig('openSameTab', false) ? '' : '_blank'}
|
||||||
|
rel='noreferrer'
|
||||||
|
className={classes.AppCard}
|
||||||
|
>
|
||||||
<div className={classes.AppCardIcon}>
|
<div className={classes.AppCardIcon}>
|
||||||
<Icon icon={iconParser(props.app.icon)} />
|
{(/.(jpeg|jpg|png)$/).test(props.app.icon)
|
||||||
|
? <img
|
||||||
|
src={`/uploads/${props.app.icon}`}
|
||||||
|
alt={`${props.app.name} icon`}
|
||||||
|
className={classes.CustomIcon}
|
||||||
|
/>
|
||||||
|
: <Icon icon={iconParser(props.app.icon)} />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.AppCardDetails}>
|
<div className={classes.AppCardDetails}>
|
||||||
<h5>{props.app.name}</h5>
|
<h5>{props.app.name}</h5>
|
||||||
<span>{props.app.url}</span>
|
<span>{displayUrl}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
|||||||
7
client/src/components/Apps/AppForm/AppForm.module.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.Switch {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Switch:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -3,18 +3,23 @@ import { connect } from 'react-redux';
|
|||||||
import { addApp, updateApp } from '../../../store/actions';
|
import { addApp, updateApp } from '../../../store/actions';
|
||||||
import { App, NewApp } from '../../../interfaces';
|
import { App, NewApp } from '../../../interfaces';
|
||||||
|
|
||||||
|
import classes from './AppForm.module.css';
|
||||||
|
|
||||||
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
||||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
import Button from '../../UI/Buttons/Button/Button';
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
modalHandler: () => void;
|
modalHandler: () => void;
|
||||||
addApp: (formData: NewApp) => any;
|
addApp: (formData: NewApp | FormData) => any;
|
||||||
updateApp: (id: number, formData: NewApp) => any;
|
updateApp: (id: number, formData: NewApp) => any;
|
||||||
app?: App;
|
app?: App;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppForm = (props: ComponentProps): JSX.Element => {
|
const AppForm = (props: ComponentProps): JSX.Element => {
|
||||||
|
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||||
|
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||||
const [formData, setFormData] = useState<NewApp>({
|
const [formData, setFormData] = useState<NewApp>({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
@@ -52,11 +57,27 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
if (e.target.files) {
|
||||||
|
setCustomIcon(e.target.files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!props.app) {
|
if (!props.app) {
|
||||||
props.addApp(formData);
|
if (customIcon) {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('icon', customIcon);
|
||||||
|
|
||||||
|
data.append('name', formData.name);
|
||||||
|
data.append('url', formData.url);
|
||||||
|
|
||||||
|
props.addApp(data);
|
||||||
|
} else {
|
||||||
|
props.addApp(formData);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
props.updateApp(props.app.id, formData);
|
props.updateApp(props.app.id, formData);
|
||||||
props.modalHandler();
|
props.modalHandler();
|
||||||
@@ -98,28 +119,61 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||||||
value={formData.url}
|
value={formData.url}
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
/>
|
/>
|
||||||
<span>Only urls without http[s]:// are supported</span>
|
|
||||||
</InputGroup>
|
|
||||||
<InputGroup>
|
|
||||||
<label htmlFor='icon'>App Icon</label>
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
name='icon'
|
|
||||||
id='icon'
|
|
||||||
placeholder='book-open-outline'
|
|
||||||
required
|
|
||||||
value={formData.icon}
|
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
|
||||||
/>
|
|
||||||
<span>
|
<span>
|
||||||
Use icon name from MDI.
|
|
||||||
<a
|
<a
|
||||||
href='https://materialdesignicons.com/'
|
href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks'
|
||||||
target='blank'>
|
target='_blank'
|
||||||
{' '}Click here for reference
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
{' '}Check supported URL formats
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
{!useCustomIcon
|
||||||
|
// use mdi icon
|
||||||
|
? (<InputGroup>
|
||||||
|
<label htmlFor='icon'>App Icon</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
name='icon'
|
||||||
|
id='icon'
|
||||||
|
placeholder='book-open-outline'
|
||||||
|
required
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Use icon name from MDI.
|
||||||
|
<a
|
||||||
|
href='https://materialdesignicons.com/'
|
||||||
|
target='blank'>
|
||||||
|
{' '}Click here for reference
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||||
|
className={classes.Switch}>
|
||||||
|
Switch to custom icon upload
|
||||||
|
</span>
|
||||||
|
</InputGroup>)
|
||||||
|
// upload custom icon
|
||||||
|
: (<InputGroup>
|
||||||
|
<label htmlFor='icon'>App Icon</label>
|
||||||
|
<input
|
||||||
|
type='file'
|
||||||
|
name='icon'
|
||||||
|
id='icon'
|
||||||
|
required
|
||||||
|
onChange={(e) => fileChangeHandler(e)}
|
||||||
|
accept='.jpg,.jpeg,.png'
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||||
|
className={classes.Switch}>
|
||||||
|
Switch to MDI
|
||||||
|
</span>
|
||||||
|
</InputGroup>)
|
||||||
|
}
|
||||||
{!props.app
|
{!props.app
|
||||||
? <Button>Add new application</Button>
|
? <Button>Add new application</Button>
|
||||||
: <Button>Update application</Button>
|
: <Button>Update application</Button>
|
||||||
|
|||||||
@@ -9,4 +9,21 @@
|
|||||||
|
|
||||||
.TableAction:hover {
|
.TableAction:hover {
|
||||||
cursor: pointer;
|
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;
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,52 @@
|
|||||||
import { KeyboardEvent } from 'react';
|
import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||||
import { App, GlobalState } from '../../../interfaces';
|
import { Link } from 'react-router-dom';
|
||||||
import { pinApp, deleteApp } from '../../../store/actions';
|
|
||||||
|
|
||||||
|
// 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';
|
import classes from './AppTable.module.css';
|
||||||
|
|
||||||
|
// UI
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
import Table from '../../UI/Table/Table';
|
import Table from '../../UI/Table/Table';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { searchConfig } from '../../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
apps: App[];
|
apps: App[];
|
||||||
pinApp: (app: App) => void;
|
pinApp: (app: App) => void;
|
||||||
deleteApp: (id: number) => void;
|
deleteApp: (id: number) => void;
|
||||||
updateAppHandler: (app: App) => void;
|
updateAppHandler: (app: App) => void;
|
||||||
|
reorderApps: (apps: App[]) => void;
|
||||||
|
updateConfig: (formData: any) => void;
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppTable = (props: ComponentProps): JSX.Element => {
|
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 deleteAppHandler = (app: App): void => {
|
||||||
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
|
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
|
||||||
|
|
||||||
@@ -23,55 +55,111 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Support keyboard navigation for actions
|
||||||
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
|
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handler(app);
|
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 (
|
return (
|
||||||
<Table headers={[
|
<Fragment>
|
||||||
'Name',
|
<div className={classes.Message}>
|
||||||
'URL',
|
{isCustomOrder
|
||||||
'Icon',
|
? <p>You can drag and drop single rows to reorder application</p>
|
||||||
'Actions'
|
: <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
|
||||||
]}>
|
}
|
||||||
{props.apps.map((app: App): JSX.Element => {
|
</div>
|
||||||
return (
|
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||||
<tr key={app.id}>
|
<Droppable droppableId='apps'>
|
||||||
<td>{app.name}</td>
|
{(provided) => (
|
||||||
<td>{app.url}</td>
|
<Table headers={[
|
||||||
<td>{app.icon}</td>
|
'Name',
|
||||||
<td className={classes.TableActions}>
|
'URL',
|
||||||
<div
|
'Icon',
|
||||||
className={classes.TableAction}
|
'Actions'
|
||||||
onClick={() => deleteAppHandler(app)}
|
]}
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
innerRef={provided.innerRef}>
|
||||||
tabIndex={0}>
|
{localApps.map((app: App, index): JSX.Element => {
|
||||||
<Icon icon='mdiDelete' />
|
return (
|
||||||
</div>
|
<Draggable key={app.id} draggableId={app.id.toString()} index={index}>
|
||||||
<div
|
{(provided, snapshot) => {
|
||||||
className={classes.TableAction}
|
const style = {
|
||||||
onClick={() => props.updateAppHandler(app)}
|
border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
borderRadius: '4px',
|
||||||
tabIndex={0}>
|
...provided.draggableProps.style,
|
||||||
<Icon icon='mdiPencil' />
|
};
|
||||||
</div>
|
|
||||||
<div
|
return (
|
||||||
className={classes.TableAction}
|
<tr
|
||||||
onClick={() => props.pinApp(app)}
|
{...provided.draggableProps}
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
|
{...provided.dragHandleProps}
|
||||||
tabIndex={0}>
|
ref={provided.innerRef}
|
||||||
{app.isPinned
|
style={style}
|
||||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
>
|
||||||
: <Icon icon='mdiPin' />
|
<td style={{ width:'200px' }}>{app.name}</td>
|
||||||
}
|
<td style={{ width:'200px' }}>{app.url}</td>
|
||||||
</div>
|
<td style={{ width:'200px' }}>{app.icon}</td>
|
||||||
</td>
|
{!snapshot.isDragging && (
|
||||||
</tr>
|
<td className={classes.TableActions}>
|
||||||
)
|
<div
|
||||||
})}
|
className={classes.TableAction}
|
||||||
</Table>
|
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);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
// Redux
|
// Redux
|
||||||
@@ -30,6 +30,12 @@ interface ComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Apps = (props: ComponentProps): JSX.Element => {
|
const Apps = (props: ComponentProps): JSX.Element => {
|
||||||
|
const {
|
||||||
|
getApps,
|
||||||
|
apps,
|
||||||
|
loading
|
||||||
|
} = props;
|
||||||
|
|
||||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||||
const [isInEdit, setIsInEdit] = useState(false);
|
const [isInEdit, setIsInEdit] = useState(false);
|
||||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||||
@@ -38,16 +44,17 @@ const Apps = (props: ComponentProps): JSX.Element => {
|
|||||||
url: 'string',
|
url: 'string',
|
||||||
icon: 'string',
|
icon: 'string',
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
orderId: 0,
|
||||||
id: 0,
|
id: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.apps.length === 0) {
|
if (apps.length === 0) {
|
||||||
props.getApps();
|
getApps();
|
||||||
}
|
}
|
||||||
}, [props.getApps]);
|
}, [getApps]);
|
||||||
|
|
||||||
const toggleModal = (): void => {
|
const toggleModal = (): void => {
|
||||||
setModalIsOpen(!modalIsOpen);
|
setModalIsOpen(!modalIsOpen);
|
||||||
@@ -93,10 +100,10 @@ const Apps = (props: ComponentProps): JSX.Element => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.Apps}>
|
<div className={classes.Apps}>
|
||||||
{props.loading
|
{loading
|
||||||
? <Spinner />
|
? <Spinner />
|
||||||
: (!isInEdit
|
: (!isInEdit
|
||||||
? <AppGrid apps={props.apps} />
|
? <AppGrid apps={apps} />
|
||||||
: <AppTable updateAppHandler={toggleUpdate} />)
|
: <AppTable updateAppHandler={toggleUpdate} />)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Bookmark, Category } from '../../../interfaces';
|
|||||||
import classes from './BookmarkCard.module.css';
|
import classes from './BookmarkCard.module.css';
|
||||||
|
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
import { iconParser } from '../../../utility/iconParser';
|
import { iconParser, urlParser, searchConfig } from '../../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
@@ -13,19 +13,24 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||||||
<div className={classes.BookmarkCard}>
|
<div className={classes.BookmarkCard}>
|
||||||
<h3>{props.category.name}</h3>
|
<h3>{props.category.name}</h3>
|
||||||
<div className={classes.Bookmarks}>
|
<div className={classes.Bookmarks}>
|
||||||
{props.category.bookmarks.map((bookmark: Bookmark) => (
|
{props.category.bookmarks.map((bookmark: Bookmark) => {
|
||||||
<a
|
const redirectUrl = urlParser(bookmark.url)[1];
|
||||||
href={`http://${bookmark.url}`}
|
|
||||||
target='_blank'
|
return (
|
||||||
key={`bookmark-${bookmark.id}`}>
|
<a
|
||||||
{bookmark.icon && (
|
href={redirectUrl}
|
||||||
<div className={classes.BookmarkIcon}>
|
target={searchConfig('openSameTab', false) ? '' : '_blank'}
|
||||||
<Icon icon={iconParser(bookmark.icon)} />
|
rel='noreferrer'
|
||||||
</div>
|
key={`bookmark-${bookmark.id}`}>
|
||||||
)}
|
{bookmark.icon && (
|
||||||
{bookmark.name}
|
<div className={classes.BookmarkIcon}>
|
||||||
</a>
|
<Icon icon={iconParser(bookmark.icon)} />
|
||||||
))}
|
</div>
|
||||||
|
)}
|
||||||
|
{bookmark.name}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -184,7 +184,15 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
|||||||
value={formData.url}
|
value={formData.url}
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
/>
|
/>
|
||||||
<span>Only urls without http[s]:// are supported</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>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='categoryId'>Bookmark Category</label>
|
<label htmlFor='categoryId'>Bookmark Category</label>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom';
|
|||||||
|
|
||||||
import classes from './BookmarkGrid.module.css';
|
import classes from './BookmarkGrid.module.css';
|
||||||
|
|
||||||
import { Bookmark, Category } from '../../../interfaces';
|
import { Category } from '../../../interfaces';
|
||||||
|
|
||||||
import BookmarkCard from '../BookmarkCard/BookmarkCard';
|
import BookmarkCard from '../BookmarkCard/BookmarkCard';
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,21 @@
|
|||||||
|
|
||||||
.TableAction:hover {
|
.TableAction:hover {
|
||||||
cursor: pointer;
|
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;
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { ContentType } from '../Bookmarks';
|
import { KeyboardEvent, useState, useEffect, Fragment } from 'react';
|
||||||
import classes from './BookmarkTable.module.css';
|
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||||
import { connect } from 'react-redux';
|
import { Link } from 'react-router-dom';
|
||||||
import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions';
|
|
||||||
import { KeyboardEvent } from 'react';
|
|
||||||
|
|
||||||
|
// 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 Table from '../../UI/Table/Table';
|
||||||
import { Bookmark, Category } from '../../../interfaces';
|
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { searchConfig } from '../../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
contentType: ContentType;
|
contentType: ContentType;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
@@ -15,9 +27,28 @@ interface ComponentProps {
|
|||||||
deleteCategory: (id: number) => void;
|
deleteCategory: (id: number) => void;
|
||||||
updateHandler: (data: Category | Bookmark) => void;
|
updateHandler: (data: Category | Bookmark) => void;
|
||||||
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
reorderCategories: (categories: Category[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
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 deleteCategoryHandler = (category: Category): void => {
|
||||||
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
|
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
|
||||||
|
|
||||||
@@ -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) {
|
if (props.contentType === ContentType.category) {
|
||||||
return (
|
return (
|
||||||
<Table headers={[
|
<Fragment>
|
||||||
'Name',
|
<div className={classes.Message}>
|
||||||
'Actions'
|
{isCustomOrder
|
||||||
]}>
|
? <p>You can drag and drop single rows to reorder categories</p>
|
||||||
{props.categories.map((category: Category) => {
|
: <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
|
||||||
return (
|
}
|
||||||
<tr key={category.id}>
|
</div>
|
||||||
<td>{category.name}</td>
|
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||||
<td className={classes.TableActions}>
|
<Droppable droppableId='categories'>
|
||||||
<div
|
{(provided) => (
|
||||||
className={classes.TableAction}
|
<Table headers={[
|
||||||
onClick={() => deleteCategoryHandler(category)}
|
'Name',
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)}
|
'Actions'
|
||||||
tabIndex={0}>
|
]}
|
||||||
<Icon icon='mdiDelete' />
|
innerRef={provided.innerRef}>
|
||||||
</div>
|
{localCategories.map((category: Category, index): JSX.Element => {
|
||||||
<div
|
return (
|
||||||
className={classes.TableAction}
|
<Draggable key={category.id} draggableId={category.id.toString()} index={index}>
|
||||||
onClick={() => props.updateHandler(category)}
|
{(provided, snapshot) => {
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
const style = {
|
||||||
tabIndex={0}>
|
border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
|
||||||
<Icon icon='mdiPencil' />
|
borderRadius: '4px',
|
||||||
</div>
|
...provided.draggableProps.style,
|
||||||
<div
|
};
|
||||||
className={classes.TableAction}
|
|
||||||
onClick={() => props.pinCategory(category)}
|
return (
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)}
|
<tr
|
||||||
tabIndex={0}>
|
{...provided.draggableProps}
|
||||||
{category.isPinned
|
{...provided.dragHandleProps}
|
||||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
ref={provided.innerRef}
|
||||||
: <Icon icon='mdiPin' />
|
style={style}
|
||||||
}
|
>
|
||||||
</div>
|
<td>{category.name}</td>
|
||||||
</td>
|
{!snapshot.isDragging && (
|
||||||
</tr>
|
<td className={classes.TableActions}>
|
||||||
)
|
<div
|
||||||
})}
|
className={classes.TableAction}
|
||||||
</Table>
|
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 {
|
} else {
|
||||||
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
|
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
|
||||||
@@ -111,14 +196,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||||||
<div
|
<div
|
||||||
className={classes.TableAction}
|
className={classes.TableAction}
|
||||||
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
|
||||||
tabIndex={0}>
|
tabIndex={0}>
|
||||||
<Icon icon='mdiDelete' />
|
<Icon icon='mdiDelete' />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={classes.TableAction}
|
className={classes.TableAction}
|
||||||
onClick={() => props.updateHandler(bookmark.bookmark)}
|
onClick={() => props.updateHandler(bookmark.bookmark)}
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
|
||||||
tabIndex={0}>
|
tabIndex={0}>
|
||||||
<Icon icon='mdiPencil' />
|
<Icon icon='mdiPencil' />
|
||||||
</div>
|
</div>
|
||||||
@@ -131,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);
|
||||||
@@ -28,6 +28,12 @@ export enum ContentType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks = (props: ComponentProps): JSX.Element => {
|
const Bookmarks = (props: ComponentProps): JSX.Element => {
|
||||||
|
const {
|
||||||
|
getCategories,
|
||||||
|
categories,
|
||||||
|
loading
|
||||||
|
} = props;
|
||||||
|
|
||||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||||
const [formContentType, setFormContentType] = useState(ContentType.category);
|
const [formContentType, setFormContentType] = useState(ContentType.category);
|
||||||
const [isInEdit, setIsInEdit] = useState(false);
|
const [isInEdit, setIsInEdit] = useState(false);
|
||||||
@@ -37,6 +43,7 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||||||
name: '',
|
name: '',
|
||||||
id: -1,
|
id: -1,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
orderId: 0,
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
@@ -52,10 +59,10 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
props.getCategories();
|
getCategories();
|
||||||
}
|
}
|
||||||
}, [props.getCategories])
|
}, [getCategories])
|
||||||
|
|
||||||
const toggleModal = (): void => {
|
const toggleModal = (): void => {
|
||||||
setModalIsOpen(!modalIsOpen);
|
setModalIsOpen(!modalIsOpen);
|
||||||
@@ -132,13 +139,13 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.loading
|
{loading
|
||||||
? <Spinner />
|
? <Spinner />
|
||||||
: (!isInEdit
|
: (!isInEdit
|
||||||
? <BookmarkGrid categories={props.categories} />
|
? <BookmarkGrid categories={categories} />
|
||||||
: <BookmarkTable
|
: <BookmarkTable
|
||||||
contentType={tableContentType}
|
contentType={tableContentType}
|
||||||
categories={props.categories}
|
categories={categories}
|
||||||
updateHandler={goToUpdateMode}
|
updateHandler={goToUpdateMode}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useState, useEffect, Fragment } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
// Redux
|
// Redux
|
||||||
@@ -22,6 +22,14 @@ import classes from './Home.module.css';
|
|||||||
import AppGrid from '../Apps/AppGrid/AppGrid';
|
import AppGrid from '../Apps/AppGrid/AppGrid';
|
||||||
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||||
import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget';
|
import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget';
|
||||||
|
import SearchBox from '../SearchBox/SearchBox';
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
import { greeter } from './functions/greeter';
|
||||||
|
import { dateTime } from './functions/dateTime';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { searchConfig } from '../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
getApps: Function;
|
getApps: Function;
|
||||||
@@ -33,69 +41,99 @@ interface ComponentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Home = (props: ComponentProps): JSX.Element => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (props.apps.length === 0) {
|
if (apps.length === 0) {
|
||||||
props.getApps();
|
getApps();
|
||||||
}
|
}
|
||||||
}, [props.getApps]);
|
}, [getApps]);
|
||||||
|
|
||||||
|
// Load bookmark categories
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
props.getCategories();
|
getCategories();
|
||||||
}
|
}
|
||||||
}, [props.getCategories]);
|
}, [getCategories]);
|
||||||
|
|
||||||
const dateAndTime = (): string => {
|
// Refresh greeter and time
|
||||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
useEffect(() => {
|
||||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
let interval: any;
|
||||||
|
|
||||||
const now = new Date();
|
// Start interval only when hideHeader is false
|
||||||
|
if (searchConfig('hideHeader', 0) !== 1) {
|
||||||
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
|
interval = setInterval(() => {
|
||||||
}
|
setHeader({
|
||||||
|
dateTime: dateTime(),
|
||||||
const greeter = (): string => {
|
greeting: greeter()
|
||||||
const now = new Date().getHours();
|
})
|
||||||
let msg: string;
|
}, 1000);
|
||||||
|
}
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<header className={classes.Header}>
|
{searchConfig('hideSearch', 0) !== 1
|
||||||
<p>{dateAndTime()}</p>
|
? <SearchBox />
|
||||||
<Link to='/settings' className={classes.SettingsLink}>Go to Settings</Link>
|
: <div></div>
|
||||||
<span className={classes.HeaderMain}>
|
|
||||||
<h1>{greeter()}</h1>
|
|
||||||
<WeatherWidget />
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<SectionHeadline title='Applications' link='/applications' />
|
|
||||||
{props.appsLoading
|
|
||||||
? <Spinner />
|
|
||||||
: <AppGrid
|
|
||||||
apps={props.apps.filter((app: App) => app.isPinned)}
|
|
||||||
totalApps={props.apps.length}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className={classes.HomeSpace}></div>
|
{searchConfig('hideHeader', 0) !== 1
|
||||||
|
? (
|
||||||
|
<header className={classes.Header}>
|
||||||
|
<p>{header.dateTime}</p>
|
||||||
|
<Link to='/settings' className={classes.SettingsLink}>Go to Settings</Link>
|
||||||
|
<span className={classes.HeaderMain}>
|
||||||
|
<h1>{header.greeting}</h1>
|
||||||
|
<WeatherWidget />
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
: <div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{searchConfig('hideApps', 0) !== 1
|
||||||
|
? (<Fragment>
|
||||||
|
<SectionHeadline title='Applications' link='/applications' />
|
||||||
|
{appsLoading
|
||||||
|
? <Spinner />
|
||||||
|
: <AppGrid
|
||||||
|
apps={apps.filter((app: App) => app.isPinned)}
|
||||||
|
totalApps={apps.length}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div className={classes.HomeSpace}></div>
|
||||||
|
</Fragment>)
|
||||||
|
: <div></div>
|
||||||
|
}
|
||||||
|
|
||||||
<SectionHeadline title='Bookmarks' link='/bookmarks' />
|
{searchConfig('hideCategories', 0) !== 1
|
||||||
{props.categoriesLoading
|
? (<Fragment>
|
||||||
? <Spinner />
|
<SectionHeadline title='Bookmarks' link='/bookmarks' />
|
||||||
: <BookmarkGrid
|
{categoriesLoading
|
||||||
categories={props.categories.filter((category: Category) => category.isPinned)}
|
? <Spinner />
|
||||||
totalCategories={props.categories.length}
|
: <BookmarkGrid
|
||||||
/>
|
categories={categories.filter((category: Category) => category.isPinned)}
|
||||||
|
totalCategories={categories.length}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Fragment>)
|
||||||
|
: <div></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<Link to='/settings' className={classes.SettingsButton}>
|
<Link to='/settings' className={classes.SettingsButton}>
|
||||||
|
|||||||
8
client/src/components/Home/functions/dateTime.ts
Normal 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()}`;
|
||||||
|
}
|
||||||
12
client/src/components/Home/functions/greeter.ts
Normal 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;
|
||||||
|
}
|
||||||
17
client/src/components/SearchBox/SearchBox.module.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.SearchBox {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
/* font-size: 20px; */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid var(--color-accent);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SearchBox:focus {
|
||||||
|
opacity: 1;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
29
client/src/components/SearchBox/SearchBox.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useRef, useEffect, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
import classes from './SearchBox.module.css';
|
||||||
|
import { searchParser } from '../../utility';
|
||||||
|
|
||||||
|
const SearchBox = (): JSX.Element => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.code === 'Enter') {
|
||||||
|
searchParser(inputRef.current.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type='text'
|
||||||
|
className={classes.SearchBox}
|
||||||
|
onKeyDown={(e) => searchHandler(e)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchBox;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.AppVersion {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.AppVersion a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
25
client/src/components/Settings/AppDetails/AppDetails.tsx
Normal 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;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.SettingsSection {
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding-bottom: 3px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 2px solid var(--color-accent);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
@@ -1,69 +1,75 @@
|
|||||||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
|
// 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 InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
import Button from '../../UI/Buttons/Button/Button';
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
import { createNotification } from '../../../store/actions';
|
|
||||||
import { ApiResponse, Config, NewNotification } from '../../../interfaces';
|
|
||||||
|
|
||||||
interface FormState {
|
// CSS
|
||||||
customTitle: string;
|
import classes from './OtherSettings.module.css';
|
||||||
pinAppsByDefault: number;
|
|
||||||
pinCategoriesByDefault: number;
|
// Utils
|
||||||
}
|
import { searchConfig } from '../../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
createNotification: (notification: NewNotification) => void;
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
updateConfig: (formData: SettingsForm) => void;
|
||||||
|
sortApps: () => void;
|
||||||
|
sortCategories: () => void;
|
||||||
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OtherSettings = (props: ComponentProps): JSX.Element => {
|
const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
const [formData, setFormData] = useState<FormState>({
|
// Initial state
|
||||||
|
const [formData, setFormData] = useState<SettingsForm>({
|
||||||
customTitle: document.title,
|
customTitle: document.title,
|
||||||
pinAppsByDefault: 0,
|
pinAppsByDefault: 1,
|
||||||
pinCategoriesByDefault: 0
|
pinCategoriesByDefault: 1,
|
||||||
|
hideHeader: 0,
|
||||||
|
hideApps: 0,
|
||||||
|
hideCategories: 0,
|
||||||
|
hideSearch: 0,
|
||||||
|
useOrdering: 'createdAt',
|
||||||
|
openSameTab: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// get initial config
|
// Get config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get<ApiResponse<Config[]>>('/api/config?keys=customTitle,pinAppsByDefault,pinCategoriesByDefault')
|
setFormData({
|
||||||
.then(data => {
|
customTitle: searchConfig('customTitle', 'Flame'),
|
||||||
let tmpFormData = { ...formData };
|
pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
|
||||||
|
pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
|
||||||
|
hideHeader: searchConfig('hideHeader', 0),
|
||||||
|
hideApps: searchConfig('hideApps', 0),
|
||||||
|
hideCategories: searchConfig('hideCategories', 0),
|
||||||
|
hideSearch: searchConfig('hideSearch', 0),
|
||||||
|
useOrdering: searchConfig('useOrdering', 'createdAt'),
|
||||||
|
openSameTab: searchConfig('openSameTab', 0)
|
||||||
|
})
|
||||||
|
}, [props.loading]);
|
||||||
|
|
||||||
data.data.data.forEach((config: Config) => {
|
// Form handler
|
||||||
let value: string | number = config.value;
|
const formSubmitHandler = async (e: FormEvent) => {
|
||||||
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();
|
e.preventDefault();
|
||||||
|
|
||||||
axios.put<ApiResponse<{}>>('/api/config', formData)
|
// Save settings
|
||||||
.then(() => {
|
await props.updateConfig(formData);
|
||||||
props.createNotification({
|
|
||||||
title: 'Success',
|
|
||||||
message: 'Settings updated'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
|
|
||||||
// update local page title
|
// Update local page title
|
||||||
localStorage.setItem('customTitle', formData.customTitle);
|
|
||||||
document.title = formData.customTitle;
|
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) => {
|
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
|
||||||
let value: string | number = e.target.value;
|
let value: string | number = e.target.value;
|
||||||
|
|
||||||
@@ -79,8 +85,10 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||||
|
{/* OTHER OPTIONS */}
|
||||||
|
<h2 className={classes.SettingsSection}>Miscellaneous</h2>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='customTitle'>Custom Page Title</label>
|
<label htmlFor='customTitle'>Custom page title</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
id='customTitle'
|
id='customTitle'
|
||||||
@@ -90,6 +98,9 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* BEAHVIOR OPTIONS */}
|
||||||
|
<h2 className={classes.SettingsSection}>App Behavior</h2>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='pinAppsByDefault'>Pin new applications by default</label>
|
<label htmlFor='pinAppsByDefault'>Pin new applications by default</label>
|
||||||
<select
|
<select
|
||||||
@@ -114,9 +125,98 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||||||
<option value={0}>False</option>
|
<option value={0}>False</option>
|
||||||
</select>
|
</select>
|
||||||
</InputGroup>
|
</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>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor='openSameTab'>Open all links in the same tab</label>
|
||||||
|
<select
|
||||||
|
id='openSameTab'
|
||||||
|
name='openSameTab'
|
||||||
|
value={formData.openSameTab}
|
||||||
|
onChange={(e) => inputChangeHandler(e, true)}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* MODULES OPTIONS */}
|
||||||
|
<h2 className={classes.SettingsSection}>Modules</h2>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor='hideSearch'>Hide search bar</label>
|
||||||
|
<select
|
||||||
|
id='hideSearch'
|
||||||
|
name='hideSearch'
|
||||||
|
value={formData.hideSearch}
|
||||||
|
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='hideApps'>Hide applications</label>
|
||||||
|
<select
|
||||||
|
id='hideApps'
|
||||||
|
name='hideApps'
|
||||||
|
value={formData.hideApps}
|
||||||
|
onChange={(e) => inputChangeHandler(e, true)}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor='hideCategories'>Hide categories</label>
|
||||||
|
<select
|
||||||
|
id='hideCategories'
|
||||||
|
name='hideCategories'
|
||||||
|
value={formData.hideCategories}
|
||||||
|
onChange={(e) => inputChangeHandler(e, true)}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
<Button>Save changes</Button>
|
<Button>Save changes</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(null, { createNotification })(OtherSettings);
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
loading: state.config.loading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
createNotification,
|
||||||
|
updateConfig,
|
||||||
|
sortApps,
|
||||||
|
sortCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, actions)(OtherSettings);
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
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 classes from './Settings.module.css';
|
||||||
|
|
||||||
import { Container } from '../UI/Layout/Layout';
|
import { Container } from '../UI/Layout/Layout';
|
||||||
import Headline from '../UI/Headlines/Headline/Headline';
|
import Headline from '../UI/Headlines/Headline/Headline';
|
||||||
|
|
||||||
import Themer from '../Themer/Themer';
|
import Themer from '../Themer/Themer';
|
||||||
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
||||||
import OtherSettings from './OtherSettings/OtherSettings';
|
import OtherSettings from './OtherSettings/OtherSettings';
|
||||||
|
import AppDetails from './AppDetails/AppDetails';
|
||||||
|
import StyleSettings from './StyleSettings/StyleSettings';
|
||||||
|
|
||||||
const Settings = (): JSX.Element => {
|
const Settings = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
@@ -38,12 +41,28 @@ const Settings = (): JSX.Element => {
|
|||||||
to='/settings/other'>
|
to='/settings/other'>
|
||||||
Other
|
Other
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
className={classes.SettingsNavLink}
|
||||||
|
activeClassName={classes.SettingsNavLinkActive}
|
||||||
|
exact
|
||||||
|
to='/settings/css'>
|
||||||
|
CSS
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
className={classes.SettingsNavLink}
|
||||||
|
activeClassName={classes.SettingsNavLinkActive}
|
||||||
|
exact
|
||||||
|
to='/settings/app'>
|
||||||
|
App
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
<section className={classes.SettingsContent}>
|
<section className={classes.SettingsContent}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/settings' component={Themer} />
|
<Route exact path='/settings' component={Themer} />
|
||||||
<Route path='/settings/weather' component={WeatherSettings} />
|
<Route path='/settings/weather' component={WeatherSettings} />
|
||||||
<Route path='/settings/other' component={OtherSettings} />
|
<Route path='/settings/other' component={OtherSettings} />
|
||||||
|
<Route path='/settings/css' component={StyleSettings} />
|
||||||
|
<Route path='/settings/app' component={AppDetails} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,4 +70,4 @@ const Settings = (): JSX.Element => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(Settings);
|
export default Settings;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createNotification } from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import { ApiResponse, NewNotification } from '../../../interfaces';
|
||||||
|
|
||||||
|
// UI
|
||||||
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
|
|
||||||
|
interface ComponentProps {
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyleSettings = (props: ComponentProps): JSX.Element => {
|
||||||
|
const [customStyles, setCustomStyles] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get<ApiResponse<string>>('/api/config/0/css')
|
||||||
|
.then(data => setCustomStyles(data.data.data))
|
||||||
|
.catch(err => console.log(err.response));
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const inputChangeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCustomStyles(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSubmitHandler = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
axios.put<ApiResponse<{}>>('/api/config/0/css', { styles: customStyles })
|
||||||
|
.then(() => {
|
||||||
|
props.createNotification({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'CSS saved. Reload page to see changes'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => console.log(err.response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor='customStyles'>Custom CSS</label>
|
||||||
|
<textarea
|
||||||
|
id='customStyles'
|
||||||
|
name='customStyles'
|
||||||
|
value={customStyles}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
spellCheck={false}
|
||||||
|
></textarea>
|
||||||
|
</InputGroup>
|
||||||
|
<Button>Save CSS</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(null, { createNotification })(StyleSettings);
|
||||||
@@ -1,31 +1,77 @@
|
|||||||
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import axios from 'axios';
|
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 InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
import Button from '../../UI/Buttons/Button/Button';
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
import { createNotification } from '../../../store/actions';
|
|
||||||
|
|
||||||
interface FormState {
|
// Utils
|
||||||
WEATHER_API_KEY: string;
|
import { searchConfig } from '../../../utility';
|
||||||
lat: number;
|
|
||||||
long: number;
|
|
||||||
isCelsius: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
createNotification: (notification: NewNotification) => void;
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
updateConfig: (formData: WeatherForm) => void;
|
||||||
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
const [formData, setFormData] = useState<FormState>({
|
// Initial state
|
||||||
|
const [formData, setFormData] = useState<WeatherForm>({
|
||||||
WEATHER_API_KEY: '',
|
WEATHER_API_KEY: '',
|
||||||
lat: 0,
|
lat: 0,
|
||||||
long: 0,
|
long: 0,
|
||||||
isCelsius: 1
|
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) => {
|
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
|
||||||
let value: string | number = e.target.value;
|
let value: string | number = e.target.value;
|
||||||
|
|
||||||
@@ -39,72 +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();
|
|
||||||
|
|
||||||
// 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
|
|
||||||
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 (
|
return (
|
||||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='WEATHER_API_KEY'>API Key</label>
|
<label htmlFor='WEATHER_API_KEY'>API key</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
id='WEATHER_API_KEY'
|
id='WEATHER_API_KEY'
|
||||||
@@ -124,7 +108,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='lat'>Location Latitude</label>
|
<label htmlFor='lat'>Location latitude</label>
|
||||||
<input
|
<input
|
||||||
type='number'
|
type='number'
|
||||||
id='lat'
|
id='lat'
|
||||||
@@ -132,6 +116,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||||||
placeholder='52.22'
|
placeholder='52.22'
|
||||||
value={formData.lat}
|
value={formData.lat}
|
||||||
onChange={(e) => inputChangeHandler(e, true)}
|
onChange={(e) => inputChangeHandler(e, true)}
|
||||||
|
step='any'
|
||||||
|
lang='en-150'
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
You can use
|
You can use
|
||||||
@@ -143,7 +129,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||||||
</span>
|
</span>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='long'>Location Longitude</label>
|
<label htmlFor='long'>Location longitude</label>
|
||||||
<input
|
<input
|
||||||
type='number'
|
type='number'
|
||||||
id='long'
|
id='long'
|
||||||
@@ -151,10 +137,12 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||||||
placeholder='21.01'
|
placeholder='21.01'
|
||||||
value={formData.long}
|
value={formData.long}
|
||||||
onChange={(e) => inputChangeHandler(e, true)}
|
onChange={(e) => inputChangeHandler(e, true)}
|
||||||
|
step='any'
|
||||||
|
lang='en-150'
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='isCelsius'>Temperature Unit</label>
|
<label htmlFor='isCelsius'>Temperature unit</label>
|
||||||
<select
|
<select
|
||||||
id='isCelsius'
|
id='isCelsius'
|
||||||
name='isCelsius'
|
name='isCelsius'
|
||||||
@@ -170,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);
|
||||||
@@ -6,8 +6,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Button:hover,
|
.Button:hover {
|
||||||
.Button:focus {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-background);
|
color: var(--color-background);
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ import classes from './Button.module.css';
|
|||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
children: string;
|
children: string;
|
||||||
|
click?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = (props: ComponentProps): JSX.Element => {
|
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;
|
export default Button;
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
.InputGroup label,
|
.InputGroup label,
|
||||||
.InputGroup span,
|
.InputGroup span,
|
||||||
.InputGroup input {
|
.InputGroup input,
|
||||||
|
.InputGroup textarea {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.InputGroup input,
|
.InputGroup input,
|
||||||
.InputGroup select {
|
.InputGroup select,
|
||||||
|
.InputGroup textarea {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -30,4 +32,9 @@
|
|||||||
|
|
||||||
.InputGroup label {
|
.InputGroup label {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.InputGroup textarea {
|
||||||
|
resize: none;
|
||||||
|
height: 50vh;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MouseEvent, useRef, useEffect } from 'react';
|
import { MouseEvent, useRef } from 'react';
|
||||||
|
|
||||||
import classes from './Modal.module.css';
|
import classes from './Modal.module.css';
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,17 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Table th,
|
.Table th,
|
||||||
.Table td {
|
.Table td {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Head */
|
/* Head */
|
||||||
|
|
||||||
.Table th {
|
.Table th {
|
||||||
--header-radius: 4px;
|
--header-radius: 4px;
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
@@ -34,8 +36,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Body */
|
/* Body */
|
||||||
|
|
||||||
.Table td {
|
.Table td {
|
||||||
/* opacity: 0.5; */
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,12 @@ import classes from './Table.module.css';
|
|||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
innerRef?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = (props: ComponentProps): JSX.Element => {
|
const Table = (props: ComponentProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className={classes.TableContainer}>
|
<div className={classes.TableContainer} ref={props.innerRef}>
|
||||||
<table className={classes.Table}>
|
<table className={classes.Table}>
|
||||||
<thead className={classes.TableHead}>
|
<thead className={classes.TableHead}>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { useState, useEffect, Fragment } from 'react';
|
import { useState, useEffect, Fragment } from 'react';
|
||||||
import { Weather, ApiResponse, Config } from '../../../interfaces';
|
|
||||||
import axios from 'axios';
|
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';
|
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>({
|
const [weather, setWeather] = useState<Weather>({
|
||||||
externalLastUpdate: '',
|
externalLastUpdate: '',
|
||||||
tempC: 0,
|
tempC: 0,
|
||||||
@@ -20,11 +35,9 @@ const WeatherWidget = (): JSX.Element => {
|
|||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isCelsius, setIsCelsius] = useState(true);
|
|
||||||
|
|
||||||
// Initial request to get data
|
// Initial request to get data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// get weather
|
|
||||||
axios.get<ApiResponse<Weather[]>>('/api/weather')
|
axios.get<ApiResponse<Weather[]>>('/api/weather')
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const weatherData = data.data.data[0];
|
const weatherData = data.data.data[0];
|
||||||
@@ -34,27 +47,13 @@ const WeatherWidget = (): JSX.Element => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
})
|
})
|
||||||
.catch(err => console.log(err));
|
.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
|
// Open socket for data updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const webSocketClient = new WebSocket(`ws://${window.location.host}/socket`);
|
const socketProtocol = document.location.protocol === 'http:' ? 'ws:' : 'wss:';
|
||||||
|
const socketAddress = `${socketProtocol}//${window.location.host}/socket`;
|
||||||
webSocketClient.onopen = () => {
|
const webSocketClient = new WebSocket(socketAddress);
|
||||||
console.log('Socket: listen')
|
|
||||||
}
|
|
||||||
|
|
||||||
webSocketClient.onmessage = (e) => {
|
webSocketClient.onmessage = (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
@@ -69,9 +68,8 @@ const WeatherWidget = (): JSX.Element => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.WeatherWidget}>
|
<div className={classes.WeatherWidget}>
|
||||||
{isLoading
|
{(isLoading || props.configLoading || searchConfig('WEATHER_API_KEY', '')) &&
|
||||||
? 'loading'
|
(weather.id > 0 &&
|
||||||
: (weather.id > 0 &&
|
|
||||||
(<Fragment>
|
(<Fragment>
|
||||||
<div className={classes.WeatherIcon}>
|
<div className={classes.WeatherIcon}>
|
||||||
<WeatherIcon
|
<WeatherIcon
|
||||||
@@ -80,7 +78,7 @@ const WeatherWidget = (): JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.WeatherDetails}>
|
<div className={classes.WeatherDetails}>
|
||||||
{isCelsius
|
{searchConfig('isCelsius', true)
|
||||||
? <span>{weather.tempC}°C</span>
|
? <span>{weather.tempC}°C</span>
|
||||||
: <span>{weather.tempF}°F</span>
|
: <span>{weather.tempF}°F</span>
|
||||||
}
|
}
|
||||||
@@ -93,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);
|
||||||
@@ -5,6 +5,7 @@ export interface App extends Model {
|
|||||||
url: string;
|
url: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
orderId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewApp {
|
export interface NewApp {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Model, Bookmark } from '.';
|
|||||||
export interface Category extends Model {
|
export interface Category extends Model {
|
||||||
name: string;
|
name: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
orderId: number;
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
client/src/interfaces/Forms.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface WeatherForm {
|
||||||
|
WEATHER_API_KEY: string;
|
||||||
|
lat: number;
|
||||||
|
long: number;
|
||||||
|
isCelsius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsForm {
|
||||||
|
customTitle: string;
|
||||||
|
pinAppsByDefault: number;
|
||||||
|
pinCategoriesByDefault: number;
|
||||||
|
hideHeader: number;
|
||||||
|
hideApps: number;
|
||||||
|
hideCategories: number;
|
||||||
|
hideSearch: number;
|
||||||
|
useOrdering: string;
|
||||||
|
openSameTab: number;
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import { State as AppState } from '../store/reducers/app';
|
|||||||
import { State as ThemeState } from '../store/reducers/theme';
|
import { State as ThemeState } from '../store/reducers/theme';
|
||||||
import { State as BookmarkState } from '../store/reducers/bookmark';
|
import { State as BookmarkState } from '../store/reducers/bookmark';
|
||||||
import { State as NotificationState } from '../store/reducers/notification';
|
import { State as NotificationState } from '../store/reducers/notification';
|
||||||
|
import { State as ConfigState } from '../store/reducers/config';
|
||||||
|
|
||||||
export interface GlobalState {
|
export interface GlobalState {
|
||||||
theme: ThemeState;
|
theme: ThemeState;
|
||||||
app: AppState;
|
app: AppState;
|
||||||
bookmark: BookmarkState;
|
bookmark: BookmarkState;
|
||||||
notification: NotificationState;
|
notification: NotificationState;
|
||||||
|
config: ConfigState;
|
||||||
}
|
}
|
||||||
5
client/src/interfaces/Query.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface Query {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
template: string;
|
||||||
|
}
|
||||||
@@ -6,4 +6,6 @@ export * from './Weather';
|
|||||||
export * from './Bookmark';
|
export * from './Bookmark';
|
||||||
export * from './Category';
|
export * from './Category';
|
||||||
export * from './Notification';
|
export * from './Notification';
|
||||||
export * from './Config';
|
export * from './Config';
|
||||||
|
export * from './Forms';
|
||||||
|
export * from './Query';
|
||||||
@@ -5,11 +5,16 @@ module.exports = function (app) {
|
|||||||
target: 'http://localhost:5005'
|
target: 'http://localhost:5005'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const assetsProxy = createProxyMiddleware('/uploads', {
|
||||||
|
target: 'http://localhost:5005'
|
||||||
|
})
|
||||||
|
|
||||||
const wsProxy = createProxyMiddleware('/socket', {
|
const wsProxy = createProxyMiddleware('/socket', {
|
||||||
target: 'http://localhost:5005',
|
target: 'http://localhost:5005',
|
||||||
ws: true
|
ws: true
|
||||||
})
|
})
|
||||||
|
|
||||||
app.use(apiProxy);
|
app.use(apiProxy);
|
||||||
|
app.use(assetsProxy);
|
||||||
app.use(wsProxy);
|
app.use(wsProxy);
|
||||||
};
|
};
|
||||||
@@ -7,19 +7,26 @@ import {
|
|||||||
AddAppAction,
|
AddAppAction,
|
||||||
DeleteAppAction,
|
DeleteAppAction,
|
||||||
UpdateAppAction,
|
UpdateAppAction,
|
||||||
|
ReorderAppsAction,
|
||||||
|
SortAppsAction,
|
||||||
// Categories
|
// Categories
|
||||||
GetCategoriesAction,
|
GetCategoriesAction,
|
||||||
AddCategoryAction,
|
AddCategoryAction,
|
||||||
PinCategoryAction,
|
PinCategoryAction,
|
||||||
DeleteCategoryAction,
|
DeleteCategoryAction,
|
||||||
UpdateCategoryAction,
|
UpdateCategoryAction,
|
||||||
|
SortCategoriesAction,
|
||||||
|
ReorderCategoriesAction,
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
AddBookmarkAction,
|
AddBookmarkAction,
|
||||||
DeleteBookmarkAction,
|
DeleteBookmarkAction,
|
||||||
UpdateBookmarkAction,
|
UpdateBookmarkAction,
|
||||||
// Notifications
|
// Notifications
|
||||||
CreateNotificationAction,
|
CreateNotificationAction,
|
||||||
ClearNotificationAction
|
ClearNotificationAction,
|
||||||
|
// Config
|
||||||
|
GetConfigAction,
|
||||||
|
UpdateConfigAction
|
||||||
} from './';
|
} from './';
|
||||||
|
|
||||||
export enum ActionTypes {
|
export enum ActionTypes {
|
||||||
@@ -34,6 +41,8 @@ export enum ActionTypes {
|
|||||||
addAppSuccess = 'ADD_APP_SUCCESS',
|
addAppSuccess = 'ADD_APP_SUCCESS',
|
||||||
deleteApp = 'DELETE_APP',
|
deleteApp = 'DELETE_APP',
|
||||||
updateApp = 'UPDATE_APP',
|
updateApp = 'UPDATE_APP',
|
||||||
|
reorderApps = 'REORDER_APPS',
|
||||||
|
sortApps = 'SORT_APPS',
|
||||||
// Categories
|
// Categories
|
||||||
getCategories = 'GET_CATEGORIES',
|
getCategories = 'GET_CATEGORIES',
|
||||||
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
|
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
|
||||||
@@ -42,13 +51,18 @@ export enum ActionTypes {
|
|||||||
pinCategory = 'PIN_CATEGORY',
|
pinCategory = 'PIN_CATEGORY',
|
||||||
deleteCategory = 'DELETE_CATEGORY',
|
deleteCategory = 'DELETE_CATEGORY',
|
||||||
updateCategory = 'UPDATE_CATEGORY',
|
updateCategory = 'UPDATE_CATEGORY',
|
||||||
|
sortCategories = 'SORT_CATEGORIES',
|
||||||
|
reorderCategories = 'REORDER_CATEGORIES',
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
addBookmark = 'ADD_BOOKMARK',
|
addBookmark = 'ADD_BOOKMARK',
|
||||||
deleteBookmark = 'DELETE_BOOKMARK',
|
deleteBookmark = 'DELETE_BOOKMARK',
|
||||||
updateBookmark = 'UPDATE_BOOKMARK',
|
updateBookmark = 'UPDATE_BOOKMARK',
|
||||||
// Notifications
|
// Notifications
|
||||||
createNotification = 'CREATE_NOTIFICATION',
|
createNotification = 'CREATE_NOTIFICATION',
|
||||||
clearNotification = 'CLEAR_NOTIFICATION'
|
clearNotification = 'CLEAR_NOTIFICATION',
|
||||||
|
// Config
|
||||||
|
getConfig = 'GET_CONFIG',
|
||||||
|
updateConfig = 'UPDATE_CONFIG'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
@@ -60,16 +74,23 @@ export type Action =
|
|||||||
AddAppAction |
|
AddAppAction |
|
||||||
DeleteAppAction |
|
DeleteAppAction |
|
||||||
UpdateAppAction |
|
UpdateAppAction |
|
||||||
|
ReorderAppsAction |
|
||||||
|
SortAppsAction |
|
||||||
// Categories
|
// Categories
|
||||||
GetCategoriesAction<any> |
|
GetCategoriesAction<any> |
|
||||||
AddCategoryAction |
|
AddCategoryAction |
|
||||||
PinCategoryAction |
|
PinCategoryAction |
|
||||||
DeleteCategoryAction |
|
DeleteCategoryAction |
|
||||||
UpdateCategoryAction |
|
UpdateCategoryAction |
|
||||||
|
SortCategoriesAction |
|
||||||
|
ReorderCategoriesAction |
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
AddBookmarkAction |
|
AddBookmarkAction |
|
||||||
DeleteBookmarkAction |
|
DeleteBookmarkAction |
|
||||||
UpdateBookmarkAction |
|
UpdateBookmarkAction |
|
||||||
// Notifications
|
// Notifications
|
||||||
CreateNotificationAction |
|
CreateNotificationAction |
|
||||||
ClearNotificationAction;
|
ClearNotificationAction |
|
||||||
|
// Config
|
||||||
|
GetConfigAction |
|
||||||
|
UpdateConfigAction;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { ActionTypes } from './actionTypes';
|
import { ActionTypes } from './actionTypes';
|
||||||
import { App, ApiResponse, NewApp } from '../../interfaces';
|
import { App, ApiResponse, NewApp, Config } from '../../interfaces';
|
||||||
import { CreateNotificationAction } from './notification';
|
import { CreateNotificationAction } from './notification';
|
||||||
|
|
||||||
export interface GetAppsAction<T> {
|
export interface GetAppsAction<T> {
|
||||||
@@ -61,7 +61,7 @@ export interface AddAppAction {
|
|||||||
payload: App;
|
payload: App;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addApp = (formData: NewApp) => async (dispatch: Dispatch) => {
|
export const addApp = (formData: NewApp | FormData) => async (dispatch: Dispatch) => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post<ApiResponse<App>>('/api/apps', formData);
|
const res = await axios.post<ApiResponse<App>>('/api/apps', formData);
|
||||||
|
|
||||||
@@ -69,14 +69,17 @@ export const addApp = (formData: NewApp) => async (dispatch: Dispatch) => {
|
|||||||
type: ActionTypes.createNotification,
|
type: ActionTypes.createNotification,
|
||||||
payload: {
|
payload: {
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: `App ${formData.name} added`
|
message: `App added`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
dispatch<AddAppAction>({
|
await dispatch<AddAppAction>({
|
||||||
type: ActionTypes.addAppSuccess,
|
type: ActionTypes.addAppSuccess,
|
||||||
payload: res.data.data
|
payload: res.data.data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sort apps
|
||||||
|
dispatch<any>(sortApps())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
@@ -89,7 +92,7 @@ export interface DeleteAppAction {
|
|||||||
|
|
||||||
export const deleteApp = (id: number) => async (dispatch: Dispatch) => {
|
export const deleteApp = (id: number) => async (dispatch: Dispatch) => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.delete<ApiResponse<{}>>(`/api/apps/${id}`);
|
await axios.delete<ApiResponse<{}>>(`/api/apps/${id}`);
|
||||||
|
|
||||||
dispatch<CreateNotificationAction>({
|
dispatch<CreateNotificationAction>({
|
||||||
type: ActionTypes.createNotification,
|
type: ActionTypes.createNotification,
|
||||||
@@ -113,7 +116,7 @@ export interface UpdateAppAction {
|
|||||||
payload: App;
|
payload: App;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateApp = (id: number, formData: NewApp) => async (dispatch: Dispatch) => {
|
export const updateApp = (id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.put<ApiResponse<App>>(`/api/apps/${id}`, formData);
|
const res = await axios.put<ApiResponse<App>>(`/api/apps/${id}`, formData);
|
||||||
|
|
||||||
@@ -121,14 +124,67 @@ export const updateApp = (id: number, formData: NewApp) => async (dispatch: Disp
|
|||||||
type: ActionTypes.createNotification,
|
type: ActionTypes.createNotification,
|
||||||
payload: {
|
payload: {
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: `App ${formData.name} updated`
|
message: `App updated`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
dispatch<UpdateAppAction>({
|
await dispatch<UpdateAppAction>({
|
||||||
type: ActionTypes.updateApp,
|
type: ActionTypes.updateApp,
|
||||||
payload: res.data.data
|
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) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { ActionTypes } from './actionTypes';
|
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';
|
import { CreateNotificationAction } from './notification';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +54,8 @@ export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch)
|
|||||||
type: ActionTypes.addCategory,
|
type: ActionTypes.addCategory,
|
||||||
payload: res.data.data
|
payload: res.data.data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dispatch<any>(sortCategories());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
@@ -130,7 +132,7 @@ export interface DeleteCategoryAction {
|
|||||||
|
|
||||||
export const deleteCategory = (id: number) => async (dispatch: Dispatch) => {
|
export const deleteCategory = (id: number) => async (dispatch: Dispatch) => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`);
|
await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`);
|
||||||
|
|
||||||
dispatch<CreateNotificationAction>({
|
dispatch<CreateNotificationAction>({
|
||||||
type: ActionTypes.createNotification,
|
type: ActionTypes.createNotification,
|
||||||
@@ -173,6 +175,8 @@ export const updateCategory = (id: number, formData: NewCategory) => async (disp
|
|||||||
type: ActionTypes.updateCategory,
|
type: ActionTypes.updateCategory,
|
||||||
payload: res.data.data
|
payload: res.data.data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dispatch<any>(sortCategories());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
@@ -191,7 +195,7 @@ export interface DeleteBookmarkAction {
|
|||||||
|
|
||||||
export const deleteBookmark = (bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => {
|
export const deleteBookmark = (bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`);
|
await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`);
|
||||||
|
|
||||||
dispatch<CreateNotificationAction>({
|
dispatch<CreateNotificationAction>({
|
||||||
type: ActionTypes.createNotification,
|
type: ActionTypes.createNotification,
|
||||||
@@ -261,4 +265,60 @@ export const updateBookmark = (bookmarkId: number, formData: NewBookmark, previo
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
52
client/src/store/actions/config.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@ export * from './theme';
|
|||||||
export * from './app';
|
export * from './app';
|
||||||
export * from './actionTypes';
|
export * from './actionTypes';
|
||||||
export * from './bookmark';
|
export * from './bookmark';
|
||||||
export * from './notification';
|
export * from './notification';
|
||||||
|
export * from './config';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ActionTypes, Action } from '../actions';
|
import { ActionTypes, Action } from '../actions';
|
||||||
import { App } from '../../interfaces/App';
|
import { App } from '../../interfaces/App';
|
||||||
|
import { sortData } from '../../utility';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -52,11 +53,9 @@ const pinApp = (state: State, action: Action): State => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addAppSuccess = (state: State, action: Action): State => {
|
const addAppSuccess = (state: State, action: Action): State => {
|
||||||
const tmpApps = [...state.apps, action.payload];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...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) => {
|
const appReducer = (state = initialState, action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionTypes.getApps: return getApps(state, action);
|
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.addAppSuccess: return addAppSuccess(state, action);
|
||||||
case ActionTypes.deleteApp: return deleteApp(state, action);
|
case ActionTypes.deleteApp: return deleteApp(state, action);
|
||||||
case ActionTypes.updateApp: return updateApp(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;
|
default: return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ActionTypes, Action } from '../actions';
|
import { ActionTypes, Action } from '../actions';
|
||||||
import { Category, Bookmark } from '../../interfaces';
|
import { Category, Bookmark } from '../../interfaces';
|
||||||
|
import { sortData } from '../../utility';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
loading: boolean;
|
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) => {
|
const bookmarkReducer = (state = initialState, action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionTypes.getCategories: return getCategories(state, action);
|
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.updateCategory: return updateCategory(state, action);
|
||||||
case ActionTypes.deleteBookmark: return deleteBookmark(state, action);
|
case ActionTypes.deleteBookmark: return deleteBookmark(state, action);
|
||||||
case ActionTypes.updateBookmark: return updateBookmark(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;
|
default: return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
client/src/store/reducers/config.ts
Normal 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;
|
||||||
@@ -6,12 +6,14 @@ import themeReducer from './theme';
|
|||||||
import appReducer from './app';
|
import appReducer from './app';
|
||||||
import bookmarkReducer from './bookmark';
|
import bookmarkReducer from './bookmark';
|
||||||
import notificationReducer from './notification';
|
import notificationReducer from './notification';
|
||||||
|
import configReducer from './config';
|
||||||
|
|
||||||
const rootReducer = combineReducers<GlobalState>({
|
const rootReducer = combineReducers<GlobalState>({
|
||||||
theme: themeReducer,
|
theme: themeReducer,
|
||||||
app: appReducer,
|
app: appReducer,
|
||||||
bookmark: bookmarkReducer,
|
bookmark: bookmarkReducer,
|
||||||
notification: notificationReducer
|
notification: notificationReducer,
|
||||||
|
config: configReducer
|
||||||
})
|
})
|
||||||
|
|
||||||
export default rootReducer;
|
export default rootReducer;
|
||||||
@@ -4,6 +4,4 @@ import thunk from 'redux-thunk';
|
|||||||
import rootReducer from './reducers';
|
import rootReducer from './reducers';
|
||||||
const initialState = {};
|
const initialState = {};
|
||||||
|
|
||||||
const store = createStore(rootReducer, initialState, composeWithDevTools(applyMiddleware(thunk)));
|
export const store = createStore(rootReducer, initialState, composeWithDevTools(applyMiddleware(thunk)));
|
||||||
|
|
||||||
export default store;
|
|
||||||
27
client/src/utility/checkVersion.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* 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 => {
|
export const iconParser = (mdiName: string): string => {
|
||||||
let parsedName = mdiName
|
let parsedName = mdiName
|
||||||
.split('-')
|
.split('-')
|
||||||
|
|||||||
6
client/src/utility/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './iconParser';
|
||||||
|
export * from './urlParser';
|
||||||
|
export * from './searchConfig';
|
||||||
|
export * from './checkVersion';
|
||||||
|
export * from './sortData';
|
||||||
|
export * from './searchParser';
|
||||||
24
client/src/utility/searchConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
22
client/src/utility/searchParser.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { queries } from './searchQueries.json';
|
||||||
|
import { Query } from '../interfaces';
|
||||||
|
|
||||||
|
import { searchConfig } from '.';
|
||||||
|
|
||||||
|
export const searchParser = (searchQuery: string): void => {
|
||||||
|
const space = searchQuery.indexOf(' ');
|
||||||
|
const prefix = searchQuery.slice(1, space);
|
||||||
|
const search = encodeURIComponent(searchQuery.slice(space + 1));
|
||||||
|
|
||||||
|
const query = queries.find((q: Query) => q.prefix === prefix);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
const sameTab = searchConfig('openSameTab', false);
|
||||||
|
|
||||||
|
if (sameTab) {
|
||||||
|
document.location.replace(`${query.template}${search}`);
|
||||||
|
} else {
|
||||||
|
window.open(`${query.template}${search}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
client/src/utility/searchQueries.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"name": "Google",
|
||||||
|
"prefix": "g",
|
||||||
|
"template": "https://www.google.com/search?q="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DuckDuckGo",
|
||||||
|
"prefix": "d",
|
||||||
|
"template": "https://duckduckgo.com/?q="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Disroot",
|
||||||
|
"prefix": "ds",
|
||||||
|
"template": "http://search.disroot.org/search?q="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "YouTube",
|
||||||
|
"prefix": "yt",
|
||||||
|
"template": "https://www.youtube.com/results?search_query="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reddit",
|
||||||
|
"prefix": "r",
|
||||||
|
"template": "https://www.reddit.com/search?q="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IMDb",
|
||||||
|
"prefix": "im",
|
||||||
|
"template": "https://www.imdb.com/find?q="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "The Movie Database",
|
||||||
|
"prefix": "mv",
|
||||||
|
"template": "https://www.themoviedb.org/search?query="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
29
client/src/utility/sortData.ts
Normal 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;
|
||||||
|
}
|
||||||
20
client/src/utility/urlParser.ts
Normal 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]
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ const asyncWrapper = require('../middleware/asyncWrapper');
|
|||||||
const ErrorResponse = require('../utils/ErrorResponse');
|
const ErrorResponse = require('../utils/ErrorResponse');
|
||||||
const App = require('../models/App');
|
const App = require('../models/App');
|
||||||
const Config = require('../models/Config');
|
const Config = require('../models/Config');
|
||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
|
||||||
// @desc Create new app
|
// @desc Create new app
|
||||||
// @route POST /api/apps
|
// @route POST /api/apps
|
||||||
@@ -13,11 +14,17 @@ exports.createApp = asyncWrapper(async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let app;
|
let app;
|
||||||
|
let _body = { ...req.body };
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
_body.icon = req.file.filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (pinApps) {
|
if (pinApps) {
|
||||||
if (parseInt(pinApps.value)) {
|
if (parseInt(pinApps.value)) {
|
||||||
app = await App.create({
|
app = await App.create({
|
||||||
...req.body,
|
..._body,
|
||||||
isPinned: true
|
isPinned: true
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -35,10 +42,24 @@ exports.createApp = asyncWrapper(async (req, res, next) => {
|
|||||||
// @route GET /api/apps
|
// @route GET /api/apps
|
||||||
// @access Public
|
// @access Public
|
||||||
exports.getApps = asyncWrapper(async (req, res, next) => {
|
exports.getApps = asyncWrapper(async (req, res, next) => {
|
||||||
const apps = await App.findAll({
|
// Get config from database
|
||||||
order: [['name', 'ASC']]
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: apps
|
data: apps
|
||||||
@@ -91,6 +112,22 @@ exports.deleteApp = asyncWrapper(async (req, res, next) => {
|
|||||||
where: { id: req.params.id }
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {}
|
data: {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const asyncWrapper = require('../middleware/asyncWrapper');
|
const asyncWrapper = require('../middleware/asyncWrapper');
|
||||||
const ErrorResponse = require('../utils/ErrorResponse');
|
const ErrorResponse = require('../utils/ErrorResponse');
|
||||||
const Bookmark = require('../models/Bookmark');
|
const Bookmark = require('../models/Bookmark');
|
||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
|
||||||
// @desc Create new bookmark
|
// @desc Create new bookmark
|
||||||
// @route POST /api/bookmarks
|
// @route POST /api/bookmarks
|
||||||
@@ -19,7 +20,7 @@ exports.createBookmark = asyncWrapper(async (req, res, next) => {
|
|||||||
// @access Public
|
// @access Public
|
||||||
exports.getBookmarks = asyncWrapper(async (req, res, next) => {
|
exports.getBookmarks = asyncWrapper(async (req, res, next) => {
|
||||||
const bookmarks = await Bookmark.findAll({
|
const bookmarks = await Bookmark.findAll({
|
||||||
order: [['name', 'ASC']]
|
order: [[ Sequelize.fn('lower', Sequelize.col('name')), 'ASC' ]]
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const ErrorResponse = require('../utils/ErrorResponse');
|
|||||||
const Category = require('../models/Category');
|
const Category = require('../models/Category');
|
||||||
const Bookmark = require('../models/Bookmark');
|
const Bookmark = require('../models/Bookmark');
|
||||||
const Config = require('../models/Config');
|
const Config = require('../models/Config');
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
// @desc Create new category
|
// @desc Create new category
|
||||||
// @route POST /api/categories
|
// @route POST /api/categories
|
||||||
@@ -36,14 +37,32 @@ exports.createCategory = asyncWrapper(async (req, res, next) => {
|
|||||||
// @route GET /api/categories
|
// @route GET /api/categories
|
||||||
// @access Public
|
// @access Public
|
||||||
exports.getCategories = asyncWrapper(async (req, res, next) => {
|
exports.getCategories = asyncWrapper(async (req, res, next) => {
|
||||||
const categories = await Category.findAll({
|
// Get config from database
|
||||||
include: [{
|
const useOrdering = await Config.findOne({
|
||||||
model: Bookmark,
|
where: { key: 'useOrdering' }
|
||||||
as: 'bookmarks'
|
|
||||||
}],
|
|
||||||
order: [['name', 'ASC']]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: categories
|
data: categories
|
||||||
@@ -118,6 +137,22 @@ exports.deleteCategory = asyncWrapper(async (req, res, next) => {
|
|||||||
where: { id: req.params.id }
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {}
|
data: {}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ const asyncWrapper = require('../middleware/asyncWrapper');
|
|||||||
const ErrorResponse = require('../utils/ErrorResponse');
|
const ErrorResponse = require('../utils/ErrorResponse');
|
||||||
const Config = require('../models/Config');
|
const Config = require('../models/Config');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
|
const File = require('../utils/File');
|
||||||
|
const { join } = require('path');
|
||||||
|
|
||||||
// @desc Insert new key:value pair
|
// @desc Insert new key:value pair
|
||||||
// @route POST /api/config
|
// @route POST /api/config
|
||||||
@@ -96,9 +98,11 @@ exports.updateValues = asyncWrapper(async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const config = await Config.findAll();
|
||||||
|
|
||||||
res.status(200).send({
|
res.status(200).send({
|
||||||
success: true,
|
success: true,
|
||||||
data: {}
|
data: config
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -120,6 +124,33 @@ exports.deletePair = asyncWrapper(async (req, res, next) => {
|
|||||||
|
|
||||||
await pair.destroy();
|
await pair.destroy();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// @desc Get custom CSS file
|
||||||
|
// @route GET /api/config/0/css
|
||||||
|
// @access Public
|
||||||
|
exports.getCss = asyncWrapper(async (req, res, next) => {
|
||||||
|
const file = new File(join(__dirname, '../public/flame.css'));
|
||||||
|
const content = file.read();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: content
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// @desc Update custom CSS file
|
||||||
|
// @route PUT /api/config/0/css
|
||||||
|
// @access Public
|
||||||
|
exports.updateCss = asyncWrapper(async (req, res, next) => {
|
||||||
|
const file = new File(join(__dirname, '../public/flame.css'));
|
||||||
|
file.write(req.body.styles);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {}
|
data: {}
|
||||||
|
|||||||
20
db.js
@@ -1,24 +1,32 @@
|
|||||||
const { Sequelize } = require('sequelize');
|
const { Sequelize } = require('sequelize');
|
||||||
|
const Logger = require('./utils/Logger');
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
const sequelize = new Sequelize({
|
const sequelize = new Sequelize({
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
storage: './data/db.sqlite',
|
storage: './data/db.sqlite',
|
||||||
logging: false
|
logging: false
|
||||||
});
|
})
|
||||||
|
|
||||||
const connectDB = async () => {
|
const connectDB = async () => {
|
||||||
try {
|
try {
|
||||||
await sequelize.authenticate();
|
await sequelize.authenticate();
|
||||||
console.log('Connected to database');
|
logger.log('Connected to database');
|
||||||
|
|
||||||
await sequelize.sync({ alter: true });
|
const syncModels = true;
|
||||||
console.log('All models were synced');
|
|
||||||
|
if (syncModels) {
|
||||||
|
logger.log('Starting model synchronization');
|
||||||
|
await sequelize.sync({ alter: true });
|
||||||
|
logger.log('All models were synchronized');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unable to connect to the database:', error);
|
logger.log(`Unable to connect to the database: ${error.message}`, 'ERROR');
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
connectDB,
|
connectDB,
|
||||||
sequelize
|
sequelize
|
||||||
};
|
}
|
||||||
10
docker-compose.yml
Normal 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
|
||||||
BIN
github/_apps.png
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 93 KiB |
BIN
github/_home.png
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 226 KiB |
@@ -1,5 +1,7 @@
|
|||||||
const ErrorResponse = require('../utils/ErrorResponse');
|
const ErrorResponse = require('../utils/ErrorResponse');
|
||||||
const colors = require('colors');
|
const colors = require('colors');
|
||||||
|
const Logger = require('../utils/Logger');
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
const errorHandler = (err, req, res, next) => {
|
const errorHandler = (err, req, res, next) => {
|
||||||
let error = { ...err };
|
let error = { ...err };
|
||||||
@@ -10,8 +12,7 @@ const errorHandler = (err, req, res, next) => {
|
|||||||
// error = new ErrorResponse(`Field ${msg}`, 400);
|
// error = new ErrorResponse(`Field ${msg}`, 400);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
console.log(error);
|
logger.log(error.message.split(',')[0], 'ERROR');
|
||||||
console.log(`${err}`);
|
|
||||||
|
|
||||||
res.status(err.statusCode || 500).json({
|
res.status(err.statusCode || 500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
29
middleware/multer.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const multer = require('multer');
|
||||||
|
|
||||||
|
if (!fs.existsSync('data/uploads')) {
|
||||||
|
fs.mkdirSync('data/uploads');
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, './data/uploads');
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
cb(null, Date.now() + '--' + file.originalname);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportedTypes = ['jpg', 'jpeg', 'png'];
|
||||||
|
|
||||||
|
const fileFilter = (req, file, cb) => {
|
||||||
|
if (supportedTypes.includes(file.mimetype.split('/')[1])) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = multer({ storage, fileFilter });
|
||||||
|
|
||||||
|
module.exports = upload.single('icon');
|
||||||
@@ -18,6 +18,11 @@ const App = sequelize.define('App', {
|
|||||||
isPinned: {
|
isPinned: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'apps'
|
tableName: 'apps'
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const Category = sequelize.define('Category', {
|
|||||||
isPinned: {
|
isPinned: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'categories'
|
tableName: 'categories'
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ const Category = require('./Category');
|
|||||||
const Bookmark = require('./Bookmark');
|
const Bookmark = require('./Bookmark');
|
||||||
|
|
||||||
const associateModels = () => {
|
const associateModels = () => {
|
||||||
// Category <> Bookmark
|
|
||||||
Category.hasMany(Bookmark, {
|
Category.hasMany(Bookmark, {
|
||||||
as: 'bookmarks',
|
foreignKey: 'categoryId',
|
||||||
|
as: 'bookmarks'
|
||||||
|
});
|
||||||
|
|
||||||
|
Bookmark.belongsTo(Category, {
|
||||||
foreignKey: 'categoryId'
|
foreignKey: 'categoryId'
|
||||||
});
|
});
|
||||||
Bookmark.belongsTo(Category, { foreignKey: 'categoryId' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = associateModels;
|
module.exports = associateModels;
|
||||||
115
package-lock.json
generated
@@ -224,6 +224,11 @@
|
|||||||
"picomatch": "^2.0.4"
|
"picomatch": "^2.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY="
|
||||||
|
},
|
||||||
"aproba": {
|
"aproba": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
|
||||||
@@ -364,6 +369,43 @@
|
|||||||
"fill-range": "^7.0.1"
|
"fill-range": "^7.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"buffer-from": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
|
||||||
|
},
|
||||||
|
"busboy": {
|
||||||
|
"version": "0.2.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
|
||||||
|
"integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=",
|
||||||
|
"requires": {
|
||||||
|
"dicer": "0.2.5",
|
||||||
|
"readable-stream": "1.1.x"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"isarray": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||||
|
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
|
||||||
|
},
|
||||||
|
"readable-stream": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||||
|
"integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
|
||||||
|
"requires": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.1",
|
||||||
|
"isarray": "0.0.1",
|
||||||
|
"string_decoder": "~0.10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"string_decoder": {
|
||||||
|
"version": "0.10.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||||
|
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"bytes": {
|
"bytes": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
|
||||||
@@ -553,6 +595,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||||
},
|
},
|
||||||
|
"concat-stream": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||||
|
"requires": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^2.2.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"concurrently": {
|
"concurrently": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-6.0.2.tgz",
|
||||||
@@ -741,6 +794,38 @@
|
|||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
|
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
|
||||||
},
|
},
|
||||||
|
"dicer": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
|
||||||
|
"integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=",
|
||||||
|
"requires": {
|
||||||
|
"readable-stream": "1.1.x",
|
||||||
|
"streamsearch": "0.1.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"isarray": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||||
|
"integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
|
||||||
|
},
|
||||||
|
"readable-stream": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||||
|
"integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
|
||||||
|
"requires": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.1",
|
||||||
|
"isarray": "0.0.1",
|
||||||
|
"string_decoder": "~0.10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"string_decoder": {
|
||||||
|
"version": "0.10.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||||
|
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"dot-prop": {
|
"dot-prop": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
|
||||||
@@ -1611,6 +1696,21 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||||
},
|
},
|
||||||
|
"multer": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==",
|
||||||
|
"requires": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^0.2.11",
|
||||||
|
"concat-stream": "^1.5.2",
|
||||||
|
"mkdirp": "^0.5.1",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"on-finished": "^2.3.0",
|
||||||
|
"type-is": "^1.6.4",
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"needle": {
|
"needle": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/needle/-/needle-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/needle/-/needle-2.6.0.tgz",
|
||||||
@@ -2411,6 +2511,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
||||||
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
|
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
|
||||||
},
|
},
|
||||||
|
"streamsearch": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
|
||||||
|
"integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo="
|
||||||
|
},
|
||||||
"string-width": {
|
"string-width": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
|
||||||
@@ -2577,6 +2682,11 @@
|
|||||||
"mime-types": "~2.1.24"
|
"mime-types": "~2.1.24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
|
||||||
|
},
|
||||||
"typedarray-to-buffer": {
|
"typedarray-to-buffer": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
|
||||||
@@ -2804,6 +2914,11 @@
|
|||||||
"integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==",
|
"integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
|
||||||
|
},
|
||||||
"y18n": {
|
"y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"concurrently": "^6.0.2",
|
"concurrently": "^6.0.2",
|
||||||
"dotenv": "^9.0.0",
|
"dotenv": "^9.0.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"multer": "^1.4.2",
|
||||||
"node-schedule": "^2.0.0",
|
"node-schedule": "^2.0.0",
|
||||||
"sequelize": "^6.6.2",
|
"sequelize": "^6.6.2",
|
||||||
"sqlite3": "^5.0.2",
|
"sqlite3": "^5.0.2",
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const upload = require('../middleware/multer');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
createApp,
|
createApp,
|
||||||
getApps,
|
getApps,
|
||||||
getApp,
|
getApp,
|
||||||
updateApp,
|
updateApp,
|
||||||
deleteApp
|
deleteApp,
|
||||||
|
reorderApps
|
||||||
} = require('../controllers/apps');
|
} = require('../controllers/apps');
|
||||||
|
|
||||||
router
|
router
|
||||||
.route('/')
|
.route('/')
|
||||||
.post(createApp)
|
.post(upload, createApp)
|
||||||
.get(getApps);
|
.get(getApps);
|
||||||
|
|
||||||
router
|
router
|
||||||
@@ -20,4 +22,8 @@ router
|
|||||||
.put(updateApp)
|
.put(updateApp)
|
||||||
.delete(deleteApp);
|
.delete(deleteApp);
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/0/reorder')
|
||||||
|
.put(reorderApps);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -6,7 +6,8 @@ const {
|
|||||||
getCategories,
|
getCategories,
|
||||||
getCategory,
|
getCategory,
|
||||||
updateCategory,
|
updateCategory,
|
||||||
deleteCategory
|
deleteCategory,
|
||||||
|
reorderCategories
|
||||||
} = require('../controllers/category');
|
} = require('../controllers/category');
|
||||||
|
|
||||||
router
|
router
|
||||||
@@ -20,4 +21,8 @@ router
|
|||||||
.put(updateCategory)
|
.put(updateCategory)
|
||||||
.delete(deleteCategory);
|
.delete(deleteCategory);
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/0/reorder')
|
||||||
|
.put(reorderCategories);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -8,6 +8,8 @@ const {
|
|||||||
updateValue,
|
updateValue,
|
||||||
updateValues,
|
updateValues,
|
||||||
deletePair,
|
deletePair,
|
||||||
|
updateCss,
|
||||||
|
getCss,
|
||||||
} = require('../controllers/config');
|
} = require('../controllers/config');
|
||||||
|
|
||||||
router
|
router
|
||||||
@@ -22,4 +24,9 @@ router
|
|||||||
.put(updateValue)
|
.put(updateValue)
|
||||||
.delete(deletePair);
|
.delete(deletePair);
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/0/css')
|
||||||
|
.get(getCss)
|
||||||
|
.put(updateCss);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
30
server.js
@@ -7,23 +7,25 @@ const Socket = require('./Socket');
|
|||||||
const Sockets = require('./Sockets');
|
const Sockets = require('./Sockets');
|
||||||
const associateModels = require('./models/associateModels');
|
const associateModels = require('./models/associateModels');
|
||||||
const initConfig = require('./utils/initConfig');
|
const initConfig = require('./utils/initConfig');
|
||||||
|
const Logger = require('./utils/Logger');
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
const PORT = process.env.PORT || 5005;
|
const PORT = process.env.PORT || 5005;
|
||||||
|
|
||||||
connectDB()
|
(async () => {
|
||||||
.then(() => {
|
await connectDB();
|
||||||
associateModels();
|
await associateModels();
|
||||||
initConfig();
|
await initConfig();
|
||||||
});
|
|
||||||
|
|
||||||
// Create server for Express API and WebSockets
|
// Create server for Express API and WebSockets
|
||||||
const server = http.createServer();
|
const server = http.createServer();
|
||||||
server.on('request', api);
|
server.on('request', api);
|
||||||
|
|
||||||
// Register weatherSocket
|
// Register weatherSocket
|
||||||
const weatherSocket = new Socket(server);
|
const weatherSocket = new Socket(server);
|
||||||
Sockets.registerSocket('weather', weatherSocket);
|
Sockets.registerSocket('weather', weatherSocket);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Server is running on port ${PORT} in ${process.env.NODE_ENV} mode`);
|
logger.log(`Server is running on port ${PORT} in ${process.env.NODE_ENV} mode`);
|
||||||
})
|
})
|
||||||
|
})();
|
||||||
25
utils/File.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
class File {
|
||||||
|
constructor(path) {
|
||||||
|
this.path = path;
|
||||||
|
this.content = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
read() {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(this.path, { encoding: 'utf-8' });
|
||||||
|
this.content = content;
|
||||||
|
return this.content;
|
||||||
|
} catch (err) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write(data) {
|
||||||
|
this.content = data;
|
||||||
|
fs.writeFileSync(this.path, this.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = File;
|
||||||
@@ -1,40 +1,39 @@
|
|||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
constructor() {
|
log(message, level = 'INFO') {
|
||||||
this.logFileHandler();
|
console.log(`[${this.generateTimestamp()}] [${level}] ${message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
logFileHandler() {
|
generateTimestamp() {
|
||||||
if (!fs.existsSync('./flame.log')) {
|
const d = new Date();
|
||||||
fs.writeFileSync('./flame.log', '');
|
|
||||||
} else {
|
// Date
|
||||||
console.log('file exists');
|
const year = d.getFullYear();
|
||||||
|
const month = this.parseDate(d.getMonth() + 1);
|
||||||
|
const day = this.parseDate(d.getDate());
|
||||||
|
|
||||||
|
// Time
|
||||||
|
const hour = this.parseDate(d.getHours());
|
||||||
|
const minutes = this.parseDate(d.getMinutes());
|
||||||
|
const seconds = this.parseDate(d.getSeconds());
|
||||||
|
const miliseconds = this.parseDate(d.getMilliseconds(), true);
|
||||||
|
|
||||||
|
// Timezone
|
||||||
|
const tz = -d.getTimezoneOffset() / 60;
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}.${miliseconds} UTC${tz >= 0 ? '+' + tz : tz}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseDate(date, ms = false) {
|
||||||
|
if (ms) {
|
||||||
|
if (date >= 10 && date < 100) {
|
||||||
|
return `0${date}`;
|
||||||
|
} else if (date < 10) {
|
||||||
|
return `00${date}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
writeLog(logMsg, logType) {
|
return date < 10 ? `0${date}` : date.toString();
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
generateLog(logMsg, logType) {
|
|
||||||
const now = new Date();
|
|
||||||
const date = `${this.parseNumber(now.getDate())}-${this.parseNumber(now.getMonth() + 1)}-${now.getFullYear()}`;
|
|
||||||
const time = `${this.parseNumber(now.getHours())}:${this.parseNumber(now.getMinutes())}:${this.parseNumber(now.getSeconds())}.${now.getMilliseconds()}`;
|
|
||||||
const log = `[${date} ${time}]: ${logType} ${logMsg}`;
|
|
||||||
return log;
|
|
||||||
// const timestamp = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
parseNumber(number) {
|
|
||||||
if (number > 9) {
|
|
||||||
return number;
|
|
||||||
} else {
|
|
||||||
return `0${number}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(logger.generateLog('testMsg', 'INFO'));
|
module.exports = Logger;
|
||||||
|
|
||||||
module.exports = new Logger();
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const Weather = require('../models/Weather');
|
const Weather = require('../models/Weather');
|
||||||
|
const Logger = require('./Logger');
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
const clearWeatherData = async () => {
|
const clearWeatherData = async () => {
|
||||||
const weather = await Weather.findOne({
|
const weather = await Weather.findOne({
|
||||||
@@ -16,7 +18,7 @@ const clearWeatherData = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Old weather data was deleted');
|
logger.log('Old weather data was deleted');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = clearWeatherData;
|
module.exports = clearWeatherData;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const Config = require('../models/Config');
|
const Config = require('../models/Config');
|
||||||
const { config } = require('./initialConfig.json');
|
const { config } = require('./initialConfig.json');
|
||||||
|
const Logger = require('./Logger');
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
const initConfig = async () => {
|
const initConfig = async () => {
|
||||||
// Get config values
|
// Get config values
|
||||||
@@ -26,7 +28,7 @@ const initConfig = async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Initial config created');
|
logger.log('Initial config created');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||