Merge pull request #21 from pawelmalak/feature

Release v1.2
This commit is contained in:
pawelmalak 2021-06-10 13:21:51 +02:00 committed by GitHub
commit 91ab1c5ae4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 289 additions and 152 deletions

View file

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

View file

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

130
client/package-lock.json generated
View file

@ -2304,6 +2304,14 @@
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
"integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA=="
},
"@types/http-proxy": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz",
"integrity": "sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ==",
"requires": {
"@types/node": "*"
}
},
"@types/istanbul-lib-coverage": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
@ -7449,110 +7457,21 @@
}
},
"http-proxy-middleware": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz",
"integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.0.tgz",
"integrity": "sha512-S+RN5njuyvYV760aiVKnyuTXqUMcSIvYOsHA891DOVQyrdZOwaXtBHpt9FUVPEDAsOvsPArZp6VXQLs44yvkow==",
"requires": {
"http-proxy": "^1.17.0",
"is-glob": "^4.0.0",
"lodash": "^4.17.11",
"micromatch": "^3.1.10"
"@types/http-proxy": "^1.17.5",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"dependencies": {
"braces": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
"integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==",
"requires": {
"arr-flatten": "^1.1.0",
"array-unique": "^0.3.2",
"extend-shallow": "^2.0.1",
"fill-range": "^4.0.0",
"isobject": "^3.0.1",
"repeat-element": "^1.1.2",
"snapdragon": "^0.8.1",
"snapdragon-node": "^2.0.1",
"split-string": "^3.0.2",
"to-regex": "^3.0.1"
},
"dependencies": {
"extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"requires": {
"is-extendable": "^0.1.0"
}
}
}
},
"fill-range": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz",
"integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=",
"requires": {
"extend-shallow": "^2.0.1",
"is-number": "^3.0.0",
"repeat-string": "^1.6.1",
"to-regex-range": "^2.1.0"
},
"dependencies": {
"extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"requires": {
"is-extendable": "^0.1.0"
}
}
}
},
"is-number": {
"is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
"integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
"requires": {
"kind-of": "^3.0.2"
},
"dependencies": {
"kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
"requires": {
"is-buffer": "^1.1.5"
}
}
}
},
"micromatch": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
"integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==",
"requires": {
"arr-diff": "^4.0.0",
"array-unique": "^0.3.2",
"braces": "^2.3.1",
"define-property": "^2.0.2",
"extend-shallow": "^3.0.2",
"extglob": "^2.0.4",
"fragment-cache": "^0.2.1",
"kind-of": "^6.0.2",
"nanomatch": "^1.2.9",
"object.pick": "^1.3.0",
"regex-not": "^1.0.0",
"snapdragon": "^0.8.1",
"to-regex": "^3.0.2"
}
},
"to-regex-range": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz",
"integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=",
"requires": {
"is-number": "^3.0.0",
"repeat-string": "^1.6.1"
}
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA=="
}
}
},
@ -16033,6 +15952,17 @@
}
}
},
"http-proxy-middleware": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz",
"integrity": "sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q==",
"requires": {
"http-proxy": "^1.17.0",
"is-glob": "^4.0.0",
"lodash": "^4.17.11",
"micromatch": "^3.1.10"
}
},
"import-local": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz",

View file

@ -15,6 +15,7 @@
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"axios": "^0.21.1",
"http-proxy-middleware": "^2.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
@ -50,6 +51,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:5005"
}
}

View file

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

View file

@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import classes from './AppCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser } from '../../../utility/iconParser';
import { App } from '../../../interfaces';
@ -11,22 +12,12 @@ interface ComponentProps {
}
const AppCard = (props: ComponentProps): JSX.Element => {
const iconParser = (mdiName: string): string => {
let parsedName = mdiName
.split('-')
.map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
.join('');
parsedName = `mdi${parsedName}`;
return parsedName;
}
const redirectHandler = (url: string): void => {
window.open(url);
}
return (
<a href={`http://${props.app.url}`} target='blank' className={classes.AppCard}>
<a href={`http://${props.app.url}`} target='_blank' className={classes.AppCard}>
<div className={classes.AppCardIcon}>
<Icon icon={iconParser(props.app.icon)} />
</div>

View file

@ -18,9 +18,18 @@
.Bookmarks a {
line-height: 2;
transition: all 0.25s;
display: flex;
}
.BookmarkCard a:hover {
text-decoration: underline;
padding-left: 10px;
}
.BookmarkIcon {
width: 20px;
height: 20px;
display: flex;
margin-top: 3px;
margin-right: 2px;
}

View file

@ -1,6 +1,9 @@
import { Bookmark, Category } from '../../../interfaces';
import classes from './BookmarkCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser } from '../../../utility/iconParser';
interface ComponentProps {
category: Category;
}
@ -13,8 +16,13 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
{props.category.bookmarks.map((bookmark: Bookmark) => (
<a
href={`http://${bookmark.url}`}
target='blank'
target='_blank'
key={`bookmark-${bookmark.id}`}>
{bookmark.icon && (
<div className={classes.BookmarkIcon}>
<Icon icon={iconParser(bookmark.icon)} />
</div>
)}
{bookmark.name}
</a>
))}

View file

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

View file

@ -96,6 +96,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
<Table headers={[
'Name',
'URL',
'Icon',
'Category',
'Actions'
]}>
@ -104,6 +105,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
<tr key={bookmark.bookmark.id}>
<td>{bookmark.bookmark.name}</td>
<td>{bookmark.bookmark.url}</td>
<td>{bookmark.bookmark.icon}</td>
<td>{bookmark.categoryName}</td>
<td className={classes.TableActions}>
<div

View file

@ -45,6 +45,7 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
name: '',
url: '',
categoryId: -1,
icon: '',
id: -1,
createdAt: new Date(),
updatedAt: new Date()

View file

@ -9,6 +9,8 @@ import { ApiResponse, Config, NewNotification } from '../../../interfaces';
interface FormState {
customTitle: string;
pinAppsByDefault: number;
pinCategoriesByDefault: number;
}
interface ComponentProps {
@ -17,12 +19,14 @@ interface ComponentProps {
const OtherSettings = (props: ComponentProps): JSX.Element => {
const [formData, setFormData] = useState<FormState>({
customTitle: document.title
customTitle: document.title,
pinAppsByDefault: 0,
pinCategoriesByDefault: 0
})
// get initial config
useEffect(() => {
axios.get<ApiResponse<Config[]>>('/api/config?keys=customTitle')
axios.get<ApiResponse<Config[]>>('/api/config?keys=customTitle,pinAppsByDefault,pinCategoriesByDefault')
.then(data => {
let tmpFormData = { ...formData };
@ -60,10 +64,16 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
document.title = formData.customTitle;
}
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
let value: string | number = e.target.value;
if (isNumber) {
value = parseFloat(value);
}
setFormData({
...formData,
[e.target.name]: e.target.value
[e.target.name]: value
})
}
@ -80,6 +90,30 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor='pinAppsByDefault'>Pin new applications by default</label>
<select
id='pinAppsByDefault'
name='pinAppsByDefault'
value={formData.pinAppsByDefault}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor='pinCategoriesByDefault'>Pin new categories by default</label>
<select
id='pinCategoriesByDefault'
name='pinCategoriesByDefault'
value={formData.pinCategoriesByDefault}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
)

View file

@ -64,6 +64,15 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
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({
@ -111,6 +120,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
target='blank'>
{' '}Weather API
</a>
. Key is required for weather module to work.
</span>
</InputGroup>
<InputGroup>

View file

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

View file

@ -50,7 +50,11 @@ const WeatherWidget = (): JSX.Element => {
// Open socket for data updates
useEffect(() => {
const webSocketClient = new WebSocket('ws://localhost:5005');
const webSocketClient = new WebSocket(`ws://${window.location.host}/socket`);
webSocketClient.onopen = () => {
console.log('Socket: listen')
}
webSocketClient.onmessage = (e) => {
const data = JSON.parse(e.data);

View file

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

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

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

View file

@ -23,10 +23,7 @@ export const getApps = () => async (dispatch: Dispatch) => {
payload: res.data.data
})
} catch (err) {
dispatch<GetAppsAction<string>>({
type: ActionTypes.getAppsError,
payload: err.data.data
})
console.log(err);
}
}

View file

@ -0,0 +1,9 @@
export const iconParser = (mdiName: string): string => {
let parsedName = mdiName
.split('-')
.map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
.join('');
parsedName = `mdi${parsedName}`;
return parsedName;
}

View file

@ -1,12 +1,29 @@
const asyncWrapper = require('../middleware/asyncWrapper');
const ErrorResponse = require('../utils/ErrorResponse');
const App = require('../models/App');
const Config = require('../models/Config');
// @desc Create new app
// @route POST /api/apps
// @access Public
exports.createApp = asyncWrapper(async (req, res, next) => {
const app = await App.create(req.body);
// Get config from database
const pinApps = await Config.findOne({
where: { key: 'pinAppsByDefault' }
});
let app;
if (pinApps) {
if (parseInt(pinApps.value)) {
app = await App.create({
...req.body,
isPinned: true
})
} else {
app = await App.create(req.body);
}
}
res.status(201).json({
success: true,

View file

@ -2,12 +2,29 @@ const asyncWrapper = require('../middleware/asyncWrapper');
const ErrorResponse = require('../utils/ErrorResponse');
const Category = require('../models/Category');
const Bookmark = require('../models/Bookmark');
const Config = require('../models/Config');
// @desc Create new category
// @route POST /api/categories
// @access Public
exports.createCategory = asyncWrapper(async (req, res, next) => {
const category = await Category.create(req.body);
// Get config from database
const pinCategories = await Config.findOne({
where: { key: 'pinCategoriesByDefault' }
});
let category;
if (pinCategories) {
if (parseInt(pinCategories.value)) {
category = await Category.create({
...req.body,
isPinned: true
})
} else {
category = await Category.create(req.body);
}
}
res.status(201).json({
success: true,

7
db.js
View file

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

View file

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

22
utils/clearWeatherData.js Normal file
View file

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

View file

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

32
utils/initialConfig.json Normal file
View file

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

View file

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