From 16341ca6da3bce3764ce871e0de6c89ae12210aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Adam=20Sowa?= Date: Sun, 10 Oct 2021 13:06:48 +0200 Subject: [PATCH 01/88] Support traefik labels for URL configuration --- controllers/apps.js | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/controllers/apps.js b/controllers/apps.js index 4376d15d..cb458a31 100644 --- a/controllers/apps.js +++ b/controllers/apps.js @@ -96,7 +96,33 @@ exports.getApps = asyncWrapper(async (req, res, next) => { containers = containers.filter((e) => Object.keys(e.Labels).length !== 0); const dockerApps = []; for (const container of containers) { - const labels = container.Labels; + let labels = container.Labels; + + if (!('flame.url' in labels)) { + for (const label of Object.keys(labels)) { + if (/^traefik.*.frontend.rule/.test(label)) { + // Traefik 1.x + let value = labels[label]; + if (value.indexOf('Host') !== -1) { + value = value.split('Host:')[1]; + labels['flame.url'] = 'https://' + value.split(',').join(';https://'); + } + } else if (/^traefik.*?\.rule/.test(label)) { + // Traefik 2.x + const value = labels[label]; + if (value.indexOf('Host') !== -1) { + const regex = /\`([a-zA-Z0-9\.\-]+)\`/g; + const domains = [] + while ((match = regex.exec(value)) != null) { + domains.push('http://' + match[1]); + } + if (domains.length > 0) { + labels['flame.url'] = domains.join(';'); + } + } + } + } + } if ( 'flame.name' in labels && From e5cba605fa2623b20124e86da24201de89d0557d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Wed, 13 Oct 2021 13:31:01 +0200 Subject: [PATCH 02/88] Search bar bug fixes --- CHANGELOG.md | 4 +++ client/src/components/SearchBar/SearchBar.tsx | 27 ++++++++++++++----- client/src/store/reducers/theme.ts | 24 +++++++++-------- client/src/utility/searchParser.ts | 2 +- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c68e1b..54d5274b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### v1.7.1 (TBA) +- Fixed search action not being triggered by Numpad Enter +- Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118)) + ### v1.7.0 (2021-10-11) - Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67)) - Users can now add their custom search providers ([#71](https://github.com/pawelmalak/flame/issues/71)) diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index 887a2ef9..85175ff7 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -27,6 +27,11 @@ const SearchBar = (props: ComponentProps): JSX.Element => { inputRef.current.focus(); }, []); + const clearSearch = () => { + inputRef.current.value = ''; + setLocalSearch(''); + }; + const searchHandler = (e: KeyboardEvent) => { const { isLocal, search, query, isURL, sameTab } = searchParser( inputRef.current.value @@ -36,31 +41,39 @@ const SearchBar = (props: ComponentProps): JSX.Element => { setLocalSearch(search); } - if (e.code === 'Enter') { + if (e.code === 'Enter' || e.code === 'NumpadEnter') { if (!query.prefix) { + // Prefix not found -> emit notification createNotification({ title: 'Error', message: 'Prefix not found', }); } else if (isURL) { + // URL or IP passed -> redirect const url = urlParser(inputRef.current.value)[1]; redirectUrl(url, sameTab); } else if (isLocal) { + // Local query -> filter apps and bookmarks setLocalSearch(search); } else { + // Valid query -> redirect to search results const url = `${query.template}${search}`; redirectUrl(url, sameTab); } + } else if (e.code === 'Escape') { + clearSearch(); } }; return ( - searchHandler(e)} - /> +
+ searchHandler(e)} + /> +
); }; diff --git a/client/src/store/reducers/theme.ts b/client/src/store/reducers/theme.ts index fabcc4b2..6adc225d 100644 --- a/client/src/store/reducers/theme.ts +++ b/client/src/store/reducers/theme.ts @@ -7,20 +7,22 @@ export interface State { const initialState: State = { theme: { - name: 'blues', + name: 'tron', colors: { - background: '#2B2C56', - primary: '#EFF1FC', - accent: '#6677EB' - } - } -} + background: '#242B33', + primary: '#EFFBFF', + accent: '#6EE2FF', + }, + }, +}; const themeReducer = (state = initialState, action: Action) => { switch (action.type) { - case ActionTypes.setTheme: return { theme: action.payload }; - default: return state; + case ActionTypes.setTheme: + return { theme: action.payload }; + default: + return state; } -} +}; -export default themeReducer; \ No newline at end of file +export default themeReducer; diff --git a/client/src/utility/searchParser.ts b/client/src/utility/searchParser.ts index 2befdd25..e14617cf 100644 --- a/client/src/utility/searchParser.ts +++ b/client/src/utility/searchParser.ts @@ -20,7 +20,7 @@ export const searchParser = (searchQuery: string): SearchResult => { // Check if url or ip was passed const urlRegex = - /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; + /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/i; result.isURL = urlRegex.test(searchQuery); From b7de1e3d275dba1ffc7f66d482579153989f835f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Thu, 21 Oct 2021 17:14:25 +0200 Subject: [PATCH 03/88] Server: Reimplemented config system --- db/migrations/01_new-config.js | 41 +++++++++++++ utils/checkFileExists.js | 10 ++++ utils/init/index.js | 2 +- utils/init/initConfig.js | 44 +++++--------- utils/init/initialConfig.json | 102 +++++++-------------------------- utils/loadConfig.js | 18 ++++++ 6 files changed, 105 insertions(+), 112 deletions(-) create mode 100644 db/migrations/01_new-config.js create mode 100644 utils/checkFileExists.js create mode 100644 utils/loadConfig.js diff --git a/db/migrations/01_new-config.js b/db/migrations/01_new-config.js new file mode 100644 index 00000000..2c42af74 --- /dev/null +++ b/db/migrations/01_new-config.js @@ -0,0 +1,41 @@ +const { DataTypes } = require('sequelize'); +const { INTEGER, DATE, STRING, TINYINT, FLOAT, TEXT } = DataTypes; +const { readFile, writeFile, copyFile } = require('fs/promises'); +const Config = require('../../models/Config'); + +const up = async (query) => { + await copyFile('utils/init/initialConfig.json', 'data/config.json'); + + const initConfigFile = await readFile('data/config.json', 'utf-8'); + const parsedNewConfig = JSON.parse(initConfigFile); + + const existingConfig = await Config.findAll({ raw: true }); + + for (let pair of existingConfig) { + const { key, value, valueType } = pair; + + let newValue = value; + + if (valueType == 'number') { + newValue = parseFloat(value); + } else if (valueType == 'boolean') { + newValue = value == 1; + } + + parsedNewConfig[key] = newValue; + } + + const newConfig = JSON.stringify(parsedNewConfig); + await writeFile('data/config.json', newConfig); + + // await query.dropTable('config'); +}; + +const down = async (query) => { + // await query.dropTable('config'); +}; + +module.exports = { + up, + down, +}; diff --git a/utils/checkFileExists.js b/utils/checkFileExists.js new file mode 100644 index 00000000..091c24e8 --- /dev/null +++ b/utils/checkFileExists.js @@ -0,0 +1,10 @@ +const fs = require('fs'); + +const checkFileExists = (path) => { + return fs.promises + .access(path, fs.constants.F_OK) + .then(() => true) + .catch(() => false); +}; + +module.exports = checkFileExists; diff --git a/utils/init/index.js b/utils/init/index.js index a0e11a11..bbc507cf 100644 --- a/utils/init/index.js +++ b/utils/init/index.js @@ -2,8 +2,8 @@ const initConfig = require('./initConfig'); const initFiles = require('./initFiles'); const initApp = async () => { - await initConfig(); await initFiles(); + await initConfig(); }; module.exports = initApp; diff --git a/utils/init/initConfig.js b/utils/init/initConfig.js index 83ce4ea0..b7ef5d90 100644 --- a/utils/init/initConfig.js +++ b/utils/init/initConfig.js @@ -1,39 +1,25 @@ -const { Op } = require('sequelize'); -const Config = require('../../models/Config'); -const { config } = require('./initialConfig.json'); - -const Logger = require('../Logger'); -const logger = new Logger(); +const { copyFile, readFile, writeFile } = require('fs/promises'); +const checkFileExists = require('../checkFileExists'); +const initialConfig = require('./initialConfig.json'); const initConfig = async () => { - // Get config values - const configPairs = await Config.findAll({ - where: { - key: { - [Op.or]: config.map((pair) => pair.key), - }, - }, - }); + const configExists = await checkFileExists('data/config.json'); + + if (!configExists) { + await copyFile('utils/init/initialConfig.json', 'data/config.json'); + } - // Get key from each pair - const configKeys = configPairs.map((pair) => pair.key); + const existingConfig = await readFile('data/config.json', 'utf-8'); + const parsedConfig = JSON.parse(existingConfig); - // Create missing pairs - config.forEach(async ({ key, value }) => { - if (!configKeys.includes(key)) { - await Config.create({ - key, - value, - valueType: typeof value, - }); + // Add new config pairs if necessary + for (let key in initialConfig) { + if (!Object.keys(parsedConfig).includes(key)) { + parsedConfig[key] = initialConfig[key]; } - }); - - if (process.env.NODE_ENV == 'development') { - logger.log('Initial config created'); } - return; + await writeFile('data/config.json', JSON.stringify(parsedConfig)); }; module.exports = initConfig; diff --git a/utils/init/initialConfig.json b/utils/init/initialConfig.json index 18cc3b4b..f6b57a3c 100644 --- a/utils/init/initialConfig.json +++ b/utils/init/initialConfig.json @@ -1,84 +1,22 @@ { - "config": [ - { - "key": "WEATHER_API_KEY", - "value": "" - }, - { - "key": "lat", - "value": 0 - }, - { - "key": "long", - "value": 0 - }, - { - "key": "isCelsius", - "value": true - }, - { - "key": "customTitle", - "value": "Flame" - }, - { - "key": "pinAppsByDefault", - "value": true - }, - { - "key": "pinCategoriesByDefault", - "value": true - }, - { - "key": "hideHeader", - "value": false - }, - { - "key": "useOrdering", - "value": "createdAt" - }, - { - "key": "appsSameTab", - "value": false - }, - { - "key": "bookmarksSameTab", - "value": false - }, - { - "key": "searchSameTab", - "value": false - }, - { - "key": "hideApps", - "value": false - }, - { - "key": "hideCategories", - "value": false - }, - { - "key": "hideSearch", - "value": false - }, - { - "key": "defaultSearchProvider", - "value": "l" - }, - { - "key": "dockerApps", - "value": false - }, - { - "key": "dockerHost", - "value": "localhost" - }, - { - "key": "kubernetesApps", - "value": false - }, - { - "key": "unpinStoppedApps", - "value": false - } - ] + "WEATHER_API_KEY": "", + "lat": 0, + "long": 0, + "isCelsius": true, + "customTitle": "Flame", + "pinAppsByDefault": true, + "pinCategoriesByDefault": true, + "hideHeader": false, + "useOrdering": "createdAt", + "appsSameTab": false, + "bookmarksSameTab": false, + "searchSameTab": false, + "hideApps": false, + "hideCategories": false, + "hideSearch": false, + "defaultSearchProvider": "l", + "dockerApps": false, + "dockerHost": "localhost", + "kubernetesApps": false, + "unpinStoppedApps": false } diff --git a/utils/loadConfig.js b/utils/loadConfig.js new file mode 100644 index 00000000..dc234f13 --- /dev/null +++ b/utils/loadConfig.js @@ -0,0 +1,18 @@ +const { readFile } = require('fs/promises'); +const checkFileExists = require('../utils/checkFileExists'); +const initConfig = require('../utils/init/initConfig'); + +const loadConfig = async () => { + const configExists = await checkFileExists('data/config.json'); + + if (!configExists) { + await initConfig(); + } + + const config = await readFile('data/config.json', 'utf-8'); + const parsedConfig = JSON.parse(config); + + return parsedConfig; +}; + +module.exports = loadConfig; From 34279c8b8c19cd9536da1d7f55b15382de308b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Fri, 22 Oct 2021 00:42:27 +0200 Subject: [PATCH 04/88] Split apps controllers into separate files --- controllers/apps.js | 352 ----------------------- controllers/apps/createApp.js | 33 +++ controllers/apps/deleteApp.js | 18 ++ controllers/apps/docker/index.js | 4 + controllers/apps/docker/useDocker.js | 148 ++++++++++ controllers/apps/docker/useKubernetes.js | 70 +++++ controllers/apps/getAllApps.js | 52 ++++ controllers/apps/getSingleApp.js | 27 ++ controllers/apps/index.js | 8 + controllers/apps/reorderApps.js | 23 ++ controllers/apps/updateApp.js | 35 +++ db/index.js | 1 - middleware/asyncWrapper.js | 16 +- middleware/errorHandler.js | 12 +- routes/apps.js | 23 +- utils/getExternalWeather.js | 20 +- 16 files changed, 444 insertions(+), 398 deletions(-) delete mode 100644 controllers/apps.js create mode 100644 controllers/apps/createApp.js create mode 100644 controllers/apps/deleteApp.js create mode 100644 controllers/apps/docker/index.js create mode 100644 controllers/apps/docker/useDocker.js create mode 100644 controllers/apps/docker/useKubernetes.js create mode 100644 controllers/apps/getAllApps.js create mode 100644 controllers/apps/getSingleApp.js create mode 100644 controllers/apps/index.js create mode 100644 controllers/apps/reorderApps.js create mode 100644 controllers/apps/updateApp.js diff --git a/controllers/apps.js b/controllers/apps.js deleted file mode 100644 index 8fc7acdc..00000000 --- a/controllers/apps.js +++ /dev/null @@ -1,352 +0,0 @@ -const asyncWrapper = require('../middleware/asyncWrapper'); -const ErrorResponse = require('../utils/ErrorResponse'); -const App = require('../models/App'); -const Config = require('../models/Config'); -const { Sequelize } = require('sequelize'); -const axios = require('axios'); -const Logger = require('../utils/Logger'); -const logger = new Logger(); -const k8s = require('@kubernetes/client-node'); - -// @desc Create new app -// @route POST /api/apps -// @access Public -exports.createApp = asyncWrapper(async (req, res, next) => { - // Get config from database - const pinApps = await Config.findOne({ - where: { key: 'pinAppsByDefault' }, - }); - - let app; - let _body = { ...req.body }; - - if (req.file) { - _body.icon = req.file.filename; - } - - if (pinApps) { - if (parseInt(pinApps.value)) { - app = await App.create({ - ..._body, - isPinned: true, - }); - } else { - app = await App.create(req.body); - } - } - - res.status(201).json({ - success: true, - data: app, - }); -}); - -// @desc Get all apps -// @route GET /api/apps -// @access Public -exports.getApps = asyncWrapper(async (req, res, next) => { - // Get config from database - const useOrdering = await Config.findOne({ - where: { key: 'useOrdering' }, - }); - const useDockerApi = await Config.findOne({ - where: { key: 'dockerApps' }, - }); - const useKubernetesApi = await Config.findOne({ - where: { key: 'kubernetesApps' }, - }); - const unpinStoppedApps = await Config.findOne({ - where: { key: 'unpinStoppedApps' }, - }); - - const orderType = useOrdering ? useOrdering.value : 'createdAt'; - let apps; - - if (useDockerApi && useDockerApi.value == 1) { - let containers = null; - - const host = await Config.findOne({ - where: { key: 'dockerHost' }, - }); - - try { - if (host.value.includes('localhost')) { - let { data } = await axios.get( - `http://${host.value}/containers/json?{"status":["running"]}`, - { - socketPath: '/var/run/docker.sock', - } - ); - containers = data; - } else { - let { data } = await axios.get( - `http://${host.value}/containers/json?{"status":["running"]}` - ); - containers = data; - } - } catch { - logger.log(`Can't connect to the docker api on ${host.value}`, 'ERROR'); - } - - if (containers) { - apps = await App.findAll({ - order: [[orderType, 'ASC']], - }); - - containers = containers.filter((e) => Object.keys(e.Labels).length !== 0); - const dockerApps = []; - for (const container of containers) { - let labels = container.Labels; - - if (!('flame.url' in labels)) { - for (const label of Object.keys(labels)) { - if (/^traefik.*.frontend.rule/.test(label)) { - // Traefik 1.x - let value = labels[label]; - if (value.indexOf('Host') !== -1) { - value = value.split('Host:')[1]; - labels['flame.url'] = 'https://' + value.split(',').join(';https://'); - } - } else if (/^traefik.*?\.rule/.test(label)) { - // Traefik 2.x - const value = labels[label]; - if (value.indexOf('Host') !== -1) { - const regex = /\`([a-zA-Z0-9\.\-]+)\`/g; - const domains = [] - while ((match = regex.exec(value)) != null) { - domains.push('http://' + match[1]); - } - if (domains.length > 0) { - labels['flame.url'] = domains.join(';'); - } - } - } - } - } - - if ( - 'flame.name' in labels && - 'flame.url' in labels && - /^app/.test(labels['flame.type']) - ) { - for (let i = 0; i < labels['flame.name'].split(';').length; i++) { - const names = labels['flame.name'].split(';'); - const urls = labels['flame.url'].split(';'); - let icons = ''; - - if ('flame.icon' in labels) { - icons = labels['flame.icon'].split(';'); - } - - dockerApps.push({ - name: names[i] || names[0], - url: urls[i] || urls[0], - icon: icons[i] || 'docker', - }); - } - } - } - - if (unpinStoppedApps && unpinStoppedApps.value == 1) { - for (const app of apps) { - await app.update({ isPinned: false }); - } - } - - for (const item of dockerApps) { - if (apps.some((app) => app.name === item.name)) { - const app = apps.filter((e) => e.name === item.name)[0]; - - if ( - item.icon === 'custom' || - (item.icon === 'docker' && app.icon != 'docker') - ) { - await app.update({ - name: item.name, - url: item.url, - isPinned: true, - }); - } else { - await app.update({ - name: item.name, - url: item.url, - icon: item.icon, - isPinned: true, - }); - } - } else { - await App.create({ - name: item.name, - url: item.url, - icon: item.icon === 'custom' ? 'docker' : item.icon, - isPinned: true, - }); - } - } - } - } - - if (useKubernetesApi && useKubernetesApi.value == 1) { - let ingresses = null; - - try { - const kc = new k8s.KubeConfig(); - kc.loadFromCluster(); - const k8sNetworkingV1Api = kc.makeApiClient(k8s.NetworkingV1Api); - await k8sNetworkingV1Api.listIngressForAllNamespaces().then((res) => { - ingresses = res.body.items; - }); - } catch { - logger.log("Can't connect to the kubernetes api", 'ERROR'); - } - - if (ingresses) { - apps = await App.findAll({ - order: [[orderType, 'ASC']], - }); - - ingresses = ingresses.filter( - (e) => Object.keys(e.metadata.annotations).length !== 0 - ); - const kubernetesApps = []; - for (const ingress of ingresses) { - const annotations = ingress.metadata.annotations; - - if ( - 'flame.pawelmalak/name' in annotations && - 'flame.pawelmalak/url' in annotations && - /^app/.test(annotations['flame.pawelmalak/type']) - ) { - kubernetesApps.push({ - name: annotations['flame.pawelmalak/name'], - url: annotations['flame.pawelmalak/url'], - icon: annotations['flame.pawelmalak/icon'] || 'kubernetes', - }); - } - } - - if (unpinStoppedApps && unpinStoppedApps.value == 1) { - for (const app of apps) { - await app.update({ isPinned: false }); - } - } - - for (const item of kubernetesApps) { - if (apps.some((app) => app.name === item.name)) { - const app = apps.filter((e) => e.name === item.name)[0]; - await app.update({ ...item, isPinned: true }); - } else { - await App.create({ - ...item, - isPinned: true, - }); - } - } - } - } - - if (orderType == 'name') { - apps = await App.findAll({ - order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']], - }); - } else { - apps = await App.findAll({ - order: [[orderType, 'ASC']], - }); - } - - if (process.env.NODE_ENV === 'production') { - // Set header to fetch containers info every time - res.status(200).setHeader('Cache-Control', 'no-store').json({ - success: true, - data: apps, - }); - return; - } - - res.status(200).json({ - success: true, - data: apps, - }); -}); - -// @desc Get single app -// @route GET /api/apps/:id -// @access Public -exports.getApp = asyncWrapper(async (req, res, next) => { - const app = await App.findOne({ - where: { id: req.params.id }, - }); - - if (!app) { - return next( - new ErrorResponse(`App with id of ${req.params.id} was not found`, 404) - ); - } - - res.status(200).json({ - success: true, - data: app, - }); -}); - -// @desc Update app -// @route PUT /api/apps/:id -// @access Public -exports.updateApp = asyncWrapper(async (req, res, next) => { - let app = await App.findOne({ - where: { id: req.params.id }, - }); - - if (!app) { - return next( - new ErrorResponse(`App with id of ${req.params.id} was not found`, 404) - ); - } - - let _body = { ...req.body }; - - if (req.file) { - _body.icon = req.file.filename; - } - - app = await app.update(_body); - - res.status(200).json({ - success: true, - data: app, - }); -}); - -// @desc Delete app -// @route DELETE /api/apps/:id -// @access Public -exports.deleteApp = asyncWrapper(async (req, res, next) => { - await App.destroy({ - where: { id: req.params.id }, - }); - - res.status(200).json({ - success: true, - data: {}, - }); -}); - -// @desc Reorder apps -// @route PUT /api/apps/0/reorder -// @access Public -exports.reorderApps = asyncWrapper(async (req, res, next) => { - req.body.apps.forEach(async ({ id, orderId }) => { - await App.update( - { orderId }, - { - where: { id }, - } - ); - }); - - res.status(200).json({ - success: true, - data: {}, - }); -}); diff --git a/controllers/apps/createApp.js b/controllers/apps/createApp.js new file mode 100644 index 00000000..361e77e0 --- /dev/null +++ b/controllers/apps/createApp.js @@ -0,0 +1,33 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const App = require('../../models/App'); +const loadConfig = require('../../utils/loadConfig'); + +// @desc Create new app +// @route POST /api/apps +// @access Public +const createApp = asyncWrapper(async (req, res, next) => { + const { pinAppsByDefault } = await loadConfig(); + + let app; + let _body = { ...req.body }; + + if (req.file) { + _body.icon = req.file.filename; + } + + if (pinAppsByDefault) { + app = await App.create({ + ..._body, + isPinned: true, + }); + } else { + app = await App.create(req.body); + } + + res.status(201).json({ + success: true, + data: app, + }); +}); + +module.exports = createApp; diff --git a/controllers/apps/deleteApp.js b/controllers/apps/deleteApp.js new file mode 100644 index 00000000..ed557294 --- /dev/null +++ b/controllers/apps/deleteApp.js @@ -0,0 +1,18 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const App = require('../../models/App'); + +// @desc Delete app +// @route DELETE /api/apps/:id +// @access Public +const deleteApp = asyncWrapper(async (req, res, next) => { + await App.destroy({ + where: { id: req.params.id }, + }); + + res.status(200).json({ + success: true, + data: {}, + }); +}); + +module.exports = deleteApp; diff --git a/controllers/apps/docker/index.js b/controllers/apps/docker/index.js new file mode 100644 index 00000000..f76a9e2a --- /dev/null +++ b/controllers/apps/docker/index.js @@ -0,0 +1,4 @@ +module.exports = { + useKubernetes: require('./useKubernetes'), + useDocker: require('./useDocker'), +}; diff --git a/controllers/apps/docker/useDocker.js b/controllers/apps/docker/useDocker.js new file mode 100644 index 00000000..fcc43792 --- /dev/null +++ b/controllers/apps/docker/useDocker.js @@ -0,0 +1,148 @@ +const App = require('../../models/App'); +const axios = require('axios'); +const Logger = require('../../utils/Logger'); +const logger = new Logger(); +const loadConfig = require('../../utils/loadConfig'); + +const useDocker = async (apps) => { + const { + useOrdering: orderType, + unpinStoppedApps, + dockerHost: host, + } = await loadConfig(); + + let containers = null; + + // Get list of containers + try { + if (host.includes('localhost')) { + // Use default host + let { data } = await axios.get( + `http://${host}/containers/json?{"status":["running"]}`, + { + socketPath: '/var/run/docker.sock', + } + ); + + containers = data; + } else { + // Use custom host + let { data } = await axios.get( + `http://${host}/containers/json?{"status":["running"]}` + ); + + containers = data; + } + } catch { + logger.log(`Can't connect to the Docker API on ${host}`, 'ERROR'); + } + + if (containers) { + apps = await App.findAll({ + order: [[orderType, 'ASC']], + }); + + // Filter out containers without any annotations + containers = containers.filter((e) => Object.keys(e.Labels).length !== 0); + + const dockerApps = []; + + for (const container of containers) { + let labels = container.Labels; + + // todo + if (!('flame.url' in labels)) { + for (const label of Object.keys(labels)) { + if (/^traefik.*.frontend.rule/.test(label)) { + // Traefik 1.x + let value = labels[label]; + + if (value.indexOf('Host') !== -1) { + value = value.split('Host:')[1]; + labels['flame.url'] = + 'https://' + value.split(',').join(';https://'); + } + } else if (/^traefik.*?\.rule/.test(label)) { + // Traefik 2.x + const value = labels[label]; + + if (value.indexOf('Host') !== -1) { + const regex = /\`([a-zA-Z0-9\.\-]+)\`/g; + const domains = []; + + while ((match = regex.exec(value)) != null) { + domains.push('http://' + match[1]); + } + + if (domains.length > 0) { + labels['flame.url'] = domains.join(';'); + } + } + } + } + } + + // add each container as flame formatted app + if ( + 'flame.name' in labels && + 'flame.url' in labels && + /^app/.test(labels['flame.type']) + ) { + for (let i = 0; i < labels['flame.name'].split(';').length; i++) { + const names = labels['flame.name'].split(';'); + const urls = labels['flame.url'].split(';'); + let icons = ''; + + if ('flame.icon' in labels) { + icons = labels['flame.icon'].split(';'); + } + + dockerApps.push({ + name: names[i] || names[0], + url: urls[i] || urls[0], + icon: icons[i] || 'docker', + }); + } + } + } + + if (unpinStoppedApps) { + for (const app of apps) { + await app.update({ isPinned: false }); + } + } + + for (const item of dockerApps) { + // If app already exists, update it + if (apps.some((app) => app.name === item.name)) { + const app = apps.find((a) => a.name === item.name); + + if ( + item.icon === 'custom' || + (item.icon === 'docker' && app.icon != 'docker') + ) { + // update without overriding icon + await app.update({ + name: item.name, + url: item.url, + isPinned: true, + }); + } else { + await app.update({ + ...item, + isPinned: true, + }); + } + } else { + // else create new app + await App.create({ + ...item, + icon: item.icon === 'custom' ? 'docker' : item.icon, + isPinned: true, + }); + } + } + } +}; + +module.exports = useDocker; diff --git a/controllers/apps/docker/useKubernetes.js b/controllers/apps/docker/useKubernetes.js new file mode 100644 index 00000000..d9961cdb --- /dev/null +++ b/controllers/apps/docker/useKubernetes.js @@ -0,0 +1,70 @@ +const App = require('../../../models/App'); +const k8s = require('@kubernetes/client-node'); +const Logger = require('../../../utils/Logger'); +const logger = new Logger(); +const loadConfig = require('../../../utils/loadConfig'); + +const useKubernetes = async (apps) => { + const { useOrdering: orderType, unpinStoppedApps } = await loadConfig(); + + let ingresses = null; + + try { + const kc = new k8s.KubeConfig(); + kc.loadFromCluster(); + const k8sNetworkingV1Api = kc.makeApiClient(k8s.NetworkingV1Api); + await k8sNetworkingV1Api.listIngressForAllNamespaces().then((res) => { + ingresses = res.body.items; + }); + } catch { + logger.log("Can't connect to the Kubernetes API", 'ERROR'); + } + + if (ingresses) { + apps = await App.findAll({ + order: [[orderType, 'ASC']], + }); + + ingresses = ingresses.filter( + (e) => Object.keys(e.metadata.annotations).length !== 0 + ); + + const kubernetesApps = []; + + for (const ingress of ingresses) { + const annotations = ingress.metadata.annotations; + + if ( + 'flame.pawelmalak/name' in annotations && + 'flame.pawelmalak/url' in annotations && + /^app/.test(annotations['flame.pawelmalak/type']) + ) { + kubernetesApps.push({ + name: annotations['flame.pawelmalak/name'], + url: annotations['flame.pawelmalak/url'], + icon: annotations['flame.pawelmalak/icon'] || 'kubernetes', + }); + } + } + + if (unpinStoppedApps) { + for (const app of apps) { + await app.update({ isPinned: false }); + } + } + + for (const item of kubernetesApps) { + if (apps.some((app) => app.name === item.name)) { + const app = apps.find((a) => a.name === item.name); + await app.update({ ...item, isPinned: true }); + } else { + await App.create({ + ...item, + isPinned: true, + }); + } + } + } +}; + +module.exports = useKubernetes; diff --git a/controllers/apps/getAllApps.js b/controllers/apps/getAllApps.js new file mode 100644 index 00000000..1172e34b --- /dev/null +++ b/controllers/apps/getAllApps.js @@ -0,0 +1,52 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const App = require('../../models/App'); +const { Sequelize } = require('sequelize'); +const loadConfig = require('../../utils/loadConfig'); + +const { useKubernetes, useDocker } = require('./docker'); + +// @desc Get all apps +// @route GET /api/apps +// @access Public +const getAllApps = asyncWrapper(async (req, res, next) => { + const { + useOrdering: orderType, + dockerApps: useDockerAPI, + kubernetesApps: useKubernetesAPI, + } = await loadConfig(); + + let apps; + + if (useDockerAPI) { + await useDocker(apps); + } + + if (useKubernetesAPI) { + await useKubernetes(apps); + } + + if (orderType == 'name') { + apps = await App.findAll({ + order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']], + }); + } else { + apps = await App.findAll({ + order: [[orderType, 'ASC']], + }); + } + + if (process.env.NODE_ENV === 'production') { + // Set header to fetch containers info every time + return res.status(200).setHeader('Cache-Control', 'no-store').json({ + success: true, + data: apps, + }); + } + + res.status(200).json({ + success: true, + data: apps, + }); +}); + +module.exports = getAllApps; diff --git a/controllers/apps/getSingleApp.js b/controllers/apps/getSingleApp.js new file mode 100644 index 00000000..9a06b682 --- /dev/null +++ b/controllers/apps/getSingleApp.js @@ -0,0 +1,27 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const App = require('../../models/App'); + +// @desc Get single app +// @route GET /api/apps/:id +// @access Public +const getSingleApp = asyncWrapper(async (req, res, next) => { + const app = await App.findOne({ + where: { id: req.params.id }, + }); + + if (!app) { + return next( + new ErrorResponse( + `App with the id of ${req.params.id} was not found`, + 404 + ) + ); + } + + res.status(200).json({ + success: true, + data: app, + }); +}); + +module.exports = getSingleApp; diff --git a/controllers/apps/index.js b/controllers/apps/index.js new file mode 100644 index 00000000..01873b34 --- /dev/null +++ b/controllers/apps/index.js @@ -0,0 +1,8 @@ +module.exports = { + createApp: require('./createApp'), + getSingleApp: require('./getSingleApp'), + deleteApp: require('./deleteApp'), + updateApp: require('./updateApp'), + reorderApps: require('./reorderApps'), + getAllApps: require('./getAllApps'), +}; diff --git a/controllers/apps/reorderApps.js b/controllers/apps/reorderApps.js new file mode 100644 index 00000000..29794b31 --- /dev/null +++ b/controllers/apps/reorderApps.js @@ -0,0 +1,23 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const App = require('../../models/App'); + +// @desc Reorder apps +// @route PUT /api/apps/0/reorder +// @access Public +const reorderApps = asyncWrapper(async (req, res, next) => { + req.body.apps.forEach(async ({ id, orderId }) => { + await App.update( + { orderId }, + { + where: { id }, + } + ); + }); + + res.status(200).json({ + success: true, + data: {}, + }); +}); + +module.exports = reorderApps; diff --git a/controllers/apps/updateApp.js b/controllers/apps/updateApp.js new file mode 100644 index 00000000..2a996fb8 --- /dev/null +++ b/controllers/apps/updateApp.js @@ -0,0 +1,35 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const App = require('../../models/App'); + +// @desc Update app +// @route PUT /api/apps/:id +// @access Public +const updateApp = asyncWrapper(async (req, res, next) => { + let app = await App.findOne({ + where: { id: req.params.id }, + }); + + if (!app) { + return next( + new ErrorResponse( + `App with the id of ${req.params.id} was not found`, + 404 + ) + ); + } + + let _body = { ...req.body }; + + if (req.file) { + _body.icon = req.file.filename; + } + + app = await app.update(_body); + + res.status(200).json({ + success: true, + data: app, + }); +}); + +module.exports = updateApp; diff --git a/db/index.js b/db/index.js index 34e715f2..500a261f 100644 --- a/db/index.js +++ b/db/index.js @@ -1,6 +1,5 @@ const { Sequelize } = require('sequelize'); const { join } = require('path'); -const fs = require('fs'); const Umzug = require('umzug'); const backupDB = require('./utils/backupDb'); diff --git a/middleware/asyncWrapper.js b/middleware/asyncWrapper.js index 11b3e521..9d992713 100644 --- a/middleware/asyncWrapper.js +++ b/middleware/asyncWrapper.js @@ -1,17 +1,7 @@ -// const asyncWrapper = foo => (req, res, next) => { -// return Promise -// .resolve(foo(req, res, next)) -// .catch(next); -// } - -// module.exports = asyncWrapper; - function asyncWrapper(foo) { return function (req, res, next) { - return Promise - .resolve(foo(req, res, next)) - .catch(next); - } + return Promise.resolve(foo(req, res, next)).catch(next); + }; } -module.exports = asyncWrapper; \ No newline at end of file +module.exports = asyncWrapper; diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js index 5db2bb24..de93c35b 100644 --- a/middleware/errorHandler.js +++ b/middleware/errorHandler.js @@ -14,10 +14,14 @@ const errorHandler = (err, req, res, next) => { logger.log(error.message.split(',')[0], 'ERROR'); + if (process.env.NODE_ENV == 'development') { + console.log(err); + } + res.status(err.statusCode || 500).json({ success: false, - error: error.message || 'Server Error' - }) -} + error: error.message || 'Server Error', + }); +}; -module.exports = errorHandler; \ No newline at end of file +module.exports = errorHandler; diff --git a/routes/apps.js b/routes/apps.js index 37c02868..6f1e817e 100644 --- a/routes/apps.js +++ b/routes/apps.js @@ -4,26 +4,17 @@ const upload = require('../middleware/multer'); const { createApp, - getApps, - getApp, + getAllApps, + getSingleApp, updateApp, deleteApp, - reorderApps + reorderApps, } = require('../controllers/apps'); -router - .route('/') - .post(upload, createApp) - .get(getApps); +router.route('/').post(upload, createApp).get(getAllApps); -router - .route('/:id') - .get(getApp) - .put(upload, updateApp) - .delete(deleteApp); +router.route('/:id').get(getSingleApp).put(upload, updateApp).delete(deleteApp); -router - .route('/0/reorder') - .put(reorderApps); +router.route('/0/reorder').put(reorderApps); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/utils/getExternalWeather.js b/utils/getExternalWeather.js index 1135ef74..8b2be8da 100644 --- a/utils/getExternalWeather.js +++ b/utils/getExternalWeather.js @@ -1,15 +1,9 @@ -const Config = require('../models/Config'); const Weather = require('../models/Weather'); const axios = require('axios'); +const loadConfig = require('./loadConfig'); const getExternalWeather = async () => { - // Get config from database - const config = await Config.findAll(); - - // Find and check values - const secret = config.find(pair => pair.key === 'WEATHER_API_KEY'); - const lat = config.find(pair => pair.key === 'lat'); - const long = config.find(pair => pair.key === 'long'); + const { WEATHER_API_KEY: secret, lat, long } = await loadConfig(); if (!secret) { throw new Error('API key was not found. Weather updated failed'); @@ -21,7 +15,9 @@ const getExternalWeather = async () => { // Fetch data from external API try { - const res = await axios.get(`http://api.weatherapi.com/v1/current.json?key=${secret.value}&q=${lat.value},${long.value}`); + const res = await axios.get( + `http://api.weatherapi.com/v1/current.json?key=${secret}&q=${lat},${long}` + ); // Save weather data const cursor = res.data.current; @@ -32,12 +28,12 @@ const getExternalWeather = async () => { isDay: cursor.is_day, cloud: cursor.cloud, conditionText: cursor.condition.text, - conditionCode: cursor.condition.code + conditionCode: cursor.condition.code, }); return weatherData; } catch (err) { throw new Error('External API request failed'); } -} +}; -module.exports = getExternalWeather; \ No newline at end of file +module.exports = getExternalWeather; From 76e50624e726e511d541398595f884d77e1de049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Fri, 22 Oct 2021 13:31:02 +0200 Subject: [PATCH 05/88] Client: Implemented new config system --- .../src/components/Apps/AppCard/AppCard.tsx | 19 +- .../src/components/Apps/AppTable/AppTable.tsx | 152 +++++---- .../Bookmarks/BookmarkCard/BookmarkCard.tsx | 16 +- .../Bookmarks/BookmarkTable/BookmarkTable.tsx | 298 +++++++++++------- client/src/components/Home/Home.tsx | 17 +- .../Settings/OtherSettings/OtherSettings.tsx | 99 +++--- .../CustomQueries/CustomQueries.tsx | 12 +- .../SearchSettings/SearchSettings.tsx | 39 +-- .../WeatherSettings/WeatherSettings.tsx | 156 ++++----- .../Widgets/WeatherWidget/WeatherWidget.tsx | 76 ++--- client/src/interfaces/Config.ts | 30 +- client/src/interfaces/Forms.ts | 31 +- client/src/utility/index.ts | 3 +- client/src/utility/inputHandler.ts | 39 +++ client/src/utility/searchConfig.ts | 24 -- client/src/utility/searchParser.ts | 9 +- .../utility/templateObjects/configTemplate.ts | 24 ++ client/src/utility/templateObjects/index.ts | 2 + .../templateObjects/settingsTemplate.ts | 30 ++ 19 files changed, 627 insertions(+), 449 deletions(-) create mode 100644 client/src/utility/inputHandler.ts delete mode 100644 client/src/utility/searchConfig.ts create mode 100644 client/src/utility/templateObjects/configTemplate.ts create mode 100644 client/src/utility/templateObjects/index.ts create mode 100644 client/src/utility/templateObjects/settingsTemplate.ts diff --git a/client/src/components/Apps/AppCard/AppCard.tsx b/client/src/components/Apps/AppCard/AppCard.tsx index 172a6801..803e5dd7 100644 --- a/client/src/components/Apps/AppCard/AppCard.tsx +++ b/client/src/components/Apps/AppCard/AppCard.tsx @@ -2,12 +2,13 @@ import classes from './AppCard.module.css'; import Icon from '../../UI/Icons/Icon/Icon'; import { iconParser, urlParser } from '../../../utility'; -import { App } from '../../../interfaces'; -import { searchConfig } from '../../../utility'; +import { App, Config, GlobalState } from '../../../interfaces'; +import { connect } from 'react-redux'; interface ComponentProps { app: App; pinHandler?: Function; + config: Config; } const AppCard = (props: ComponentProps): JSX.Element => { @@ -29,7 +30,7 @@ const AppCard = (props: ComponentProps): JSX.Element => {
@@ -41,8 +42,8 @@ const AppCard = (props: ComponentProps): JSX.Element => { return (
{iconEl}
@@ -54,4 +55,10 @@ const AppCard = (props: ComponentProps): JSX.Element => { ); }; -export default AppCard; +const mapStateToProps = (state: GlobalState) => { + return { + config: state.config.config, + }; +}; + +export default connect(mapStateToProps)(AppCard); diff --git a/client/src/components/Apps/AppTable/AppTable.tsx b/client/src/components/Apps/AppTable/AppTable.tsx index 6ef6e6c1..3f68d76b 100644 --- a/client/src/components/Apps/AppTable/AppTable.tsx +++ b/client/src/components/Apps/AppTable/AppTable.tsx @@ -1,13 +1,24 @@ import { Fragment, KeyboardEvent, useState, useEffect } from 'react'; -import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from 'react-beautiful-dnd'; import { Link } from 'react-router-dom'; // Redux import { connect } from 'react-redux'; -import { pinApp, deleteApp, reorderApps, updateConfig, createNotification } from '../../../store/actions'; +import { + pinApp, + deleteApp, + reorderApps, + updateConfig, + createNotification, +} from '../../../store/actions'; // Typescript -import { App, GlobalState, NewNotification } from '../../../interfaces'; +import { App, Config, GlobalState, NewNotification } from '../../../interfaces'; // CSS import classes from './AppTable.module.css'; @@ -16,11 +27,9 @@ import classes from './AppTable.module.css'; import Icon from '../../UI/Icons/Icon/Icon'; import Table from '../../UI/Table/Table'; -// Utils -import { searchConfig } from '../../../utility'; - interface ComponentProps { apps: App[]; + config: Config; pinApp: (app: App) => void; deleteApp: (id: number) => void; updateAppHandler: (app: App) => void; @@ -36,38 +45,44 @@ const AppTable = (props: ComponentProps): JSX.Element => { // Copy apps array useEffect(() => { setLocalApps([...props.apps]); - }, [props.apps]) + }, [props.apps]); // Check ordering useEffect(() => { - const order = searchConfig('useOrdering', ''); + const order = props.config.useOrdering; if (order === 'orderId') { setIsCustomOrder(true); } - }, []) + }, []); const deleteAppHandler = (app: App): void => { - const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`); + const proceed = window.confirm( + `Are you sure you want to delete ${app.name} at ${app.url} ?` + ); if (proceed) { props.deleteApp(app.id); } - } + }; // Support keyboard navigation for actions - const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => { + const keyboardActionHandler = ( + e: KeyboardEvent, + app: App, + handler: Function + ) => { if (e.key === 'Enter') { handler(app); } - } + }; const dragEndHanlder = (result: DropResult): void => { if (!isCustomOrder) { props.createNotification({ title: 'Error', - message: 'Custom order is disabled' - }) + message: 'Custom order is disabled', + }); return; } @@ -81,32 +96,39 @@ const AppTable = (props: ComponentProps): JSX.Element => { setLocalApps(tmpApps); props.reorderApps(tmpApps); - } + }; return (
- {isCustomOrder - ?

You can drag and drop single rows to reorder application

- :

Custom order is disabled. You can change it in settings

- } + {isCustomOrder ? ( +

You can drag and drop single rows to reorder application

+ ) : ( +

+ Custom order is disabled. You can change it in{' '} + settings +

+ )}
- + {(provided) => ( - +
{localApps.map((app: App, index): JSX.Element => { return ( - + {(provided, snapshot) => { const style = { - border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none', + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', borderRadius: '4px', ...provided.draggableProps.style, }; @@ -118,63 +140,85 @@ const AppTable = (props: ComponentProps): JSX.Element => { ref={provided.innerRef} style={style} > - - - + + + {!snapshot.isDragging && ( )} - ) + ); }} - ) + ); })}
{app.name}{app.url}{app.icon}{app.name}{app.url}{app.icon}
deleteAppHandler(app)} - onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)} - tabIndex={0}> - + onKeyDown={(e) => + keyboardActionHandler( + e, + app, + deleteAppHandler + ) + } + tabIndex={0} + > +
props.updateAppHandler(app)} - onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)} - tabIndex={0}> - + onKeyDown={(e) => + keyboardActionHandler( + e, + app, + props.updateAppHandler + ) + } + tabIndex={0} + > +
props.pinApp(app)} - onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)} - tabIndex={0}> - {app.isPinned - ? - : + onKeyDown={(e) => + keyboardActionHandler(e, app, props.pinApp) } + tabIndex={0} + > + {app.isPinned ? ( + + ) : ( + + )}
)}
- ) -} + ); +}; const mapStateToProps = (state: GlobalState) => { return { - apps: state.app.apps - } -} + apps: state.app.apps, + config: state.config.config, + }; +}; const actions = { pinApp, deleteApp, reorderApps, updateConfig, - createNotification -} + createNotification, +}; -export default connect(mapStateToProps, actions)(AppTable); \ No newline at end of file +export default connect(mapStateToProps, actions)(AppTable); diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx index b332a6f4..93ead026 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx @@ -1,12 +1,14 @@ -import { Bookmark, Category } from '../../../interfaces'; +import { Bookmark, Category, Config, GlobalState } from '../../../interfaces'; import classes from './BookmarkCard.module.css'; import Icon from '../../UI/Icons/Icon/Icon'; -import { iconParser, urlParser, searchConfig } from '../../../utility'; +import { iconParser, urlParser } from '../../../utility'; import { Fragment } from 'react'; +import { connect } from 'react-redux'; interface ComponentProps { category: Category; + config: Config; } const BookmarkCard = (props: ComponentProps): JSX.Element => { @@ -54,7 +56,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => { return (
@@ -68,4 +70,10 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => { ); }; -export default BookmarkCard; +const mapStateToProps = (state: GlobalState) => { + return { + config: state.config.config, + }; +}; + +export default connect(mapStateToProps)(BookmarkCard); diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx index 02779d59..90c34aaf 100644 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx +++ b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx @@ -1,13 +1,30 @@ import { KeyboardEvent, useState, useEffect, Fragment } from 'react'; -import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from 'react-beautiful-dnd'; import { Link } from 'react-router-dom'; // Redux import { connect } from 'react-redux'; -import { pinCategory, deleteCategory, deleteBookmark, createNotification, reorderCategories } from '../../../store/actions'; +import { + pinCategory, + deleteCategory, + deleteBookmark, + createNotification, + reorderCategories, +} from '../../../store/actions'; // Typescript -import { Bookmark, Category, NewNotification } from '../../../interfaces'; +import { + Bookmark, + Category, + Config, + GlobalState, + NewNotification, +} from '../../../interfaces'; import { ContentType } from '../Bookmarks'; // CSS @@ -17,12 +34,10 @@ import classes from './BookmarkTable.module.css'; import Table from '../../UI/Table/Table'; import Icon from '../../UI/Icons/Icon/Icon'; -// Utils -import { searchConfig } from '../../../utility'; - interface ComponentProps { contentType: ContentType; categories: Category[]; + config: Config; pinCategory: (category: Category) => void; deleteCategory: (id: number) => void; updateHandler: (data: Category | Bookmark) => void; @@ -38,45 +53,53 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { // Copy categories array useEffect(() => { setLocalCategories([...props.categories]); - }, [props.categories]) + }, [props.categories]); // Check ordering useEffect(() => { - const order = searchConfig('useOrdering', ''); + const order = props.config.useOrdering; if (order === 'orderId') { setIsCustomOrder(true); } - }) + }); const deleteCategoryHandler = (category: Category): void => { - const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`); + const proceed = window.confirm( + `Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks` + ); if (proceed) { props.deleteCategory(category.id); } - } + }; const deleteBookmarkHandler = (bookmark: Bookmark): void => { - const proceed = window.confirm(`Are you sure you want to delete ${bookmark.name}?`); + const proceed = window.confirm( + `Are you sure you want to delete ${bookmark.name}?` + ); if (proceed) { props.deleteBookmark(bookmark.id, bookmark.categoryId); } - } + }; - const keyboardActionHandler = (e: KeyboardEvent, category: Category, handler: Function) => { + const keyboardActionHandler = ( + e: KeyboardEvent, + category: Category, + handler: Function + ) => { if (e.key === 'Enter') { handler(category); } - } + }; const dragEndHanlder = (result: DropResult): void => { if (!isCustomOrder) { props.createNotification({ title: 'Error', - message: 'Custom order is disabled' - }) + message: 'Custom order is disabled', + }); return; } @@ -90,136 +113,171 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { setLocalCategories(tmpCategories); props.reorderCategories(tmpCategories); - } + }; if (props.contentType === ContentType.category) { return (
- {isCustomOrder - ?

You can drag and drop single rows to reorder categories

- :

Custom order is disabled. You can change it in settings

- } + {isCustomOrder ? ( +

You can drag and drop single rows to reorder categories

+ ) : ( +

+ Custom order is disabled. You can change it in{' '} + settings +

+ )}
- + {(provided) => ( - - {localCategories.map((category: Category, index): JSX.Element => { - return ( - - {(provided, snapshot) => { - const style = { - border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none', - borderRadius: '4px', - ...provided.draggableProps.style, - }; - - return ( - - - {!snapshot.isDragging && ( - - )} - - ) - }} - - ) - })} +
{category.name} -
deleteCategoryHandler(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)} - tabIndex={0}> - -
-
props.updateHandler(category)} - tabIndex={0}> - -
-
props.pinCategory(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)} - tabIndex={0}> - {category.isPinned - ? - : - } -
-
+ {localCategories.map( + (category: Category, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + } + )}
{category.name} +
+ deleteCategoryHandler(category) + } + onKeyDown={(e) => + keyboardActionHandler( + e, + category, + deleteCategoryHandler + ) + } + tabIndex={0} + > + +
+
+ props.updateHandler(category) + } + tabIndex={0} + > + +
+
props.pinCategory(category)} + onKeyDown={(e) => + keyboardActionHandler( + e, + category, + props.pinCategory + ) + } + tabIndex={0} + > + {category.isPinned ? ( + + ) : ( + + )} +
+
)}
- ) + ); } else { - const bookmarks: {bookmark: Bookmark, categoryName: string}[] = []; + const bookmarks: { bookmark: Bookmark; categoryName: string }[] = []; props.categories.forEach((category: Category) => { category.bookmarks.forEach((bookmark: Bookmark) => { bookmarks.push({ bookmark, - categoryName: category.name + categoryName: category.name, }); - }) - }) + }); + }); return ( - - {bookmarks.map((bookmark: {bookmark: Bookmark, categoryName: string}) => { - return ( - - - - - - - - ) - })} +
{bookmark.bookmark.name}{bookmark.bookmark.url}{bookmark.bookmark.icon}{bookmark.categoryName} -
deleteBookmarkHandler(bookmark.bookmark)} - tabIndex={0}> - -
-
props.updateHandler(bookmark.bookmark)} - tabIndex={0}> - -
-
+ {bookmarks.map( + (bookmark: { bookmark: Bookmark; categoryName: string }) => { + return ( + + + + + + + + ); + } + )}
{bookmark.bookmark.name}{bookmark.bookmark.url}{bookmark.bookmark.icon}{bookmark.categoryName} +
deleteBookmarkHandler(bookmark.bookmark)} + tabIndex={0} + > + +
+
props.updateHandler(bookmark.bookmark)} + tabIndex={0} + > + +
+
- ) + ); } -} +}; + +const mapStateToProps = (state: GlobalState) => { + return { + config: state.config.config, + }; +}; const actions = { pinCategory, deleteCategory, deleteBookmark, createNotification, - reorderCategories -} + reorderCategories, +}; -export default connect(null, actions)(BookmarkTable); \ No newline at end of file +export default connect(mapStateToProps, actions)(BookmarkTable); diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index fd711aad..18d81bc9 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -7,7 +7,7 @@ import { getApps, getCategories } from '../../store/actions'; // Typescript import { GlobalState } from '../../interfaces/GlobalState'; -import { App, Category } from '../../interfaces'; +import { App, Category, Config } from '../../interfaces'; // UI import Icon from '../UI/Icons/Icon/Icon'; @@ -28,9 +28,6 @@ import SearchBar from '../SearchBar/SearchBar'; import { greeter } from './functions/greeter'; import { dateTime } from './functions/dateTime'; -// Utils -import { searchConfig } from '../../utility'; - interface ComponentProps { getApps: Function; getCategories: Function; @@ -38,6 +35,7 @@ interface ComponentProps { apps: App[]; categoriesLoading: boolean; categories: Category[]; + config: Config; } const Home = (props: ComponentProps): JSX.Element => { @@ -77,7 +75,7 @@ const Home = (props: ComponentProps): JSX.Element => { let interval: any; // Start interval only when hideHeader is false - if (searchConfig('hideHeader', 0) !== 1) { + if (!props.config.hideHeader) { interval = setInterval(() => { setHeader({ dateTime: dateTime(), @@ -103,13 +101,13 @@ const Home = (props: ComponentProps): JSX.Element => { return ( - {searchConfig('hideSearch', 0) !== 1 ? ( + {!props.config.hideSearch ? ( ) : (
)} - {searchConfig('hideHeader', 0) !== 1 ? ( + {!props.config.hideHeader ? (

{header.dateTime}

@@ -124,7 +122,7 @@ const Home = (props: ComponentProps): JSX.Element => {
)} - {searchConfig('hideApps', 0) !== 1 ? ( + {!props.config.hideApps ? ( {appsLoading ? ( @@ -148,7 +146,7 @@ const Home = (props: ComponentProps): JSX.Element => {
)} - {searchConfig('hideCategories', 0) !== 1 ? ( + {!props.config.hideCategories ? ( {categoriesLoading ? ( @@ -182,6 +180,7 @@ const mapStateToProps = (state: GlobalState) => { apps: state.app.apps, categoriesLoading: state.bookmark.loading, categories: state.bookmark.categories, + config: state.config.config, }; }; diff --git a/client/src/components/Settings/OtherSettings/OtherSettings.tsx b/client/src/components/Settings/OtherSettings/OtherSettings.tsx index c3525f8c..3d82fa44 100644 --- a/client/src/components/Settings/OtherSettings/OtherSettings.tsx +++ b/client/src/components/Settings/OtherSettings/OtherSettings.tsx @@ -11,9 +11,10 @@ import { // Typescript import { + Config, GlobalState, NewNotification, - SettingsForm, + OtherSettingsForm, } from '../../../interfaces'; // UI @@ -22,50 +23,29 @@ import Button from '../../UI/Buttons/Button/Button'; import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline'; // Utils -import { searchConfig } from '../../../utility'; +import { otherSettingsTemplate, inputHandler } from '../../../utility'; interface ComponentProps { createNotification: (notification: NewNotification) => void; - updateConfig: (formData: SettingsForm) => void; + updateConfig: (formData: OtherSettingsForm) => void; sortApps: () => void; sortCategories: () => void; loading: boolean; + config: Config; } const OtherSettings = (props: ComponentProps): JSX.Element => { + const { config } = props; + // Initial state - const [formData, setFormData] = useState({ - customTitle: document.title, - pinAppsByDefault: 1, - pinCategoriesByDefault: 1, - hideHeader: 0, - hideApps: 0, - hideCategories: 0, - useOrdering: 'createdAt', - appsSameTab: 0, - bookmarksSameTab: 0, - dockerApps: 1, - dockerHost: 'localhost', - kubernetesApps: 1, - unpinStoppedApps: 1, - }); + const [formData, setFormData] = useState( + otherSettingsTemplate + ); // Get config useEffect(() => { setFormData({ - customTitle: searchConfig('customTitle', 'Flame'), - pinAppsByDefault: searchConfig('pinAppsByDefault', 1), - pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1), - hideHeader: searchConfig('hideHeader', 0), - hideApps: searchConfig('hideApps', 0), - hideCategories: searchConfig('hideCategories', 0), - useOrdering: searchConfig('useOrdering', 'createdAt'), - appsSameTab: searchConfig('appsSameTab', 0), - bookmarksSameTab: searchConfig('bookmarksSameTab', 0), - dockerApps: searchConfig('dockerApps', 0), - dockerHost: searchConfig('dockerHost', 'localhost'), - kubernetesApps: searchConfig('kubernetesApps', 0), - unpinStoppedApps: searchConfig('unpinStoppedApps', 0), + ...config, }); }, [props.loading]); @@ -87,17 +67,13 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { // Input handler const inputChangeHandler = ( e: ChangeEvent, - isNumber?: boolean + options?: { isNumber?: boolean; isBool?: boolean } ) => { - let value: string | number = e.target.value; - - if (isNumber) { - value = parseFloat(value); - } - - setFormData({ - ...formData, - [e.target.name]: value, + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, }); }; @@ -126,8 +102,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { inputChangeHandler(e, true)} + value={formData.pinCategoriesByDefault ? 1 : 0} + onChange={(e) => inputChangeHandler(e, { isBool: true })} > @@ -165,8 +141,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { inputChangeHandler(e, true)} + value={formData.bookmarksSameTab ? 1 : 0} + onChange={(e) => inputChangeHandler(e, { isBool: true })} > @@ -192,8 +168,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { inputChangeHandler(e, true)} + value={formData.hideApps ? 1 : 0} + onChange={(e) => inputChangeHandler(e, { isBool: true })} > @@ -216,8 +192,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { inputChangeHandler(e, true)} + value={formData.dockerApps ? 1 : 0} + onChange={(e) => inputChangeHandler(e, { isBool: true })} > @@ -256,8 +232,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { inputChangeHandler(e, true)} + value={formData.kubernetesApps ? 1 : 0} + onChange={(e) => inputChangeHandler(e, { isBool: true })} > @@ -286,6 +262,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { const mapStateToProps = (state: GlobalState) => { return { loading: state.config.loading, + config: state.config.config, }; }; diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx b/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx index c5dac623..a694f427 100644 --- a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx +++ b/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx @@ -5,16 +5,21 @@ import classes from './CustomQueries.module.css'; import Modal from '../../../UI/Modal/Modal'; import Icon from '../../../UI/Icons/Icon/Icon'; -import { GlobalState, NewNotification, Query } from '../../../../interfaces'; +import { + Config, + GlobalState, + NewNotification, + Query, +} from '../../../../interfaces'; import QueriesForm from './QueriesForm'; import { deleteQuery, createNotification } from '../../../../store/actions'; import Button from '../../../UI/Buttons/Button/Button'; -import { searchConfig } from '../../../../utility'; interface Props { customQueries: Query[]; deleteQuery: (prefix: string) => {}; createNotification: (notification: NewNotification) => void; + config: Config; } const CustomQueries = (props: Props): JSX.Element => { @@ -29,7 +34,7 @@ const CustomQueries = (props: Props): JSX.Element => { }; const deleteHandler = (query: Query) => { - const currentProvider = searchConfig('defaultSearchProvider', 'l'); + const currentProvider = props.config.defaultSearchProvider; const isCurrent = currentProvider === query.prefix; if (isCurrent) { @@ -104,6 +109,7 @@ const CustomQueries = (props: Props): JSX.Element => { const mapStateToProps = (state: GlobalState) => { return { customQueries: state.config.customQueries, + config: state.config.config, }; }; diff --git a/client/src/components/Settings/SearchSettings/SearchSettings.tsx b/client/src/components/Settings/SearchSettings/SearchSettings.tsx index b2ac4224..a403fa64 100644 --- a/client/src/components/Settings/SearchSettings/SearchSettings.tsx +++ b/client/src/components/Settings/SearchSettings/SearchSettings.tsx @@ -7,6 +7,7 @@ import { createNotification, updateConfig } from '../../../store/actions'; // Typescript import { + Config, GlobalState, NewNotification, Query, @@ -22,7 +23,7 @@ import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadli import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; // Utils -import { searchConfig } from '../../../utility'; +import { inputHandler, searchSettingsTemplate } from '../../../utility'; // Data import { queries } from '../../../utility/searchQueries.json'; @@ -32,22 +33,17 @@ interface Props { updateConfig: (formData: SearchForm) => void; loading: boolean; customQueries: Query[]; + config: Config; } const SearchSettings = (props: Props): JSX.Element => { // Initial state - const [formData, setFormData] = useState({ - hideSearch: 0, - defaultSearchProvider: 'l', - searchSameTab: 0, - }); + const [formData, setFormData] = useState(searchSettingsTemplate); // Get config useEffect(() => { setFormData({ - hideSearch: searchConfig('hideSearch', 0), - defaultSearchProvider: searchConfig('defaultSearchProvider', 'l'), - searchSameTab: searchConfig('searchSameTab', 0), + ...props.config, }); }, [props.loading]); @@ -62,17 +58,13 @@ const SearchSettings = (props: Props): JSX.Element => { // Input handler const inputChangeHandler = ( e: ChangeEvent, - isNumber?: boolean + options?: { isNumber?: boolean; isBool?: boolean } ) => { - let value: string | number = e.target.value; - - if (isNumber) { - value = parseFloat(value); - } - - setFormData({ - ...formData, - [e.target.name]: value, + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, }); }; @@ -110,8 +102,8 @@ const SearchSettings = (props: Props): JSX.Element => { inputChangeHandler(e, true)} + value={formData.hideSearch ? 1 : 0} + onChange={(e) => inputChangeHandler(e, { isBool: true })} > @@ -143,6 +135,7 @@ const mapStateToProps = (state: GlobalState) => { return { loading: state.config.loading, customQueries: state.config.customQueries, + config: state.config.config, }; }; diff --git a/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx b/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx index 1378d442..04c9fa5b 100644 --- a/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx +++ b/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx @@ -6,38 +6,40 @@ import { connect } from 'react-redux'; import { createNotification, updateConfig } from '../../../store/actions'; // Typescript -import { ApiResponse, GlobalState, NewNotification, Weather, WeatherForm } from '../../../interfaces'; +import { + ApiResponse, + Config, + GlobalState, + NewNotification, + Weather, + WeatherForm, +} from '../../../interfaces'; // UI import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import Button from '../../UI/Buttons/Button/Button'; // Utils -import { searchConfig } from '../../../utility'; +import { inputHandler, weatherSettingsTemplate } from '../../../utility'; interface ComponentProps { createNotification: (notification: NewNotification) => void; updateConfig: (formData: WeatherForm) => void; loading: boolean; + config: Config; } const WeatherSettings = (props: ComponentProps): JSX.Element => { // Initial state - const [formData, setFormData] = useState({ - WEATHER_API_KEY: '', - lat: 0, - long: 0, - isCelsius: 1 - }) + const [formData, setFormData] = useState( + weatherSettingsTemplate + ); // Get config useEffect(() => { setFormData({ - WEATHER_API_KEY: searchConfig('WEATHER_API_KEY', ''), - lat: searchConfig('lat', 0), - long: searchConfig('long', 0), - isCelsius: searchConfig('isCelsius', 1) - }) + ...props.config, + }); }, [props.loading]); // Form handler @@ -48,120 +50,124 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => { if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) { props.createNotification({ title: 'Warning', - message: 'API key is missing. Weather Module will NOT work' - }) + message: 'API key is missing. Weather Module will NOT work', + }); } // Save settings await props.updateConfig(formData); - + // Update weather - axios.get>('/api/weather/update') + axios + .get>('/api/weather/update') .then(() => { props.createNotification({ title: 'Success', - message: 'Weather updated' - }) + message: 'Weather updated', + }); }) .catch((err) => { props.createNotification({ title: 'Error', - message: err.response.data.error - }) + message: err.response.data.error, + }); }); - } + }; // Input handler - const inputChangeHandler = (e: ChangeEvent, isNumber?: boolean) => { - let value: string | number = e.target.value; - - if (isNumber) { - value = parseFloat(value); - } - - setFormData({ - ...formData, - [e.target.name]: value - }) - } + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; return (
formSubmitHandler(e)}> - + inputChangeHandler(e)} /> Using - - {' '}Weather API + + {' '} + Weather API . Key is required for weather module to work. - + inputChangeHandler(e, true)} - step='any' - lang='en-150' + onChange={(e) => inputChangeHandler(e, { isNumber: true })} + step="any" + lang="en-150" /> You can use - {' '}latlong.net + href="https://www.latlong.net/convert-address-to-lat-long.html" + target="blank" + > + {' '} + latlong.net - + inputChangeHandler(e, true)} - step='any' - lang='en-150' + onChange={(e) => inputChangeHandler(e, { isNumber: true })} + step="any" + lang="en-150" /> - + - +
- ) -} + ); +}; const mapStateToProps = (state: GlobalState) => { return { - loading: state.config.loading - } -} - -export default connect(mapStateToProps, { createNotification, updateConfig })(WeatherSettings); \ No newline at end of file + loading: state.config.loading, + config: state.config.config, + }; +}; + +export default connect(mapStateToProps, { createNotification, updateConfig })( + WeatherSettings +); diff --git a/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx b/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx index edf6feef..862a398d 100644 --- a/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx +++ b/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx @@ -5,7 +5,7 @@ import axios from 'axios'; import { connect } from 'react-redux'; // Typescript -import { Weather, ApiResponse, Config, GlobalState } from '../../../interfaces'; +import { Weather, ApiResponse, GlobalState, Config } from '../../../interfaces'; // CSS import classes from './WeatherWidget.module.css'; @@ -13,12 +13,9 @@ import classes from './WeatherWidget.module.css'; // UI import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon'; -// Utils -import { searchConfig } from '../../../utility'; - interface ComponentProps { configLoading: boolean; - config: Config[]; + config: Config; } const WeatherWidget = (props: ComponentProps): JSX.Element => { @@ -32,26 +29,28 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => { conditionCode: 1000, id: -1, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }); const [isLoading, setIsLoading] = useState(true); // Initial request to get data useEffect(() => { - axios.get>('/api/weather') - .then(data => { + axios + .get>('/api/weather') + .then((data) => { const weatherData = data.data.data[0]; if (weatherData) { setWeather(weatherData); } setIsLoading(false); }) - .catch(err => console.log(err)); + .catch((err) => console.log(err)); }, []); // Open socket for data updates useEffect(() => { - const socketProtocol = document.location.protocol === 'http:' ? 'ws:' : 'wss:'; + const socketProtocol = + document.location.protocol === 'http:' ? 'ws:' : 'wss:'; const socketAddress = `${socketProtocol}//${window.location.host}/socket`; const webSocketClient = new WebSocket(socketAddress); @@ -59,43 +58,44 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => { const data = JSON.parse(e.data); setWeather({ ...weather, - ...data - }) - } + ...data, + }); + }; return () => webSocketClient.close(); }, []); return (
- {(isLoading || props.configLoading || searchConfig('WEATHER_API_KEY', '')) && - (weather.id > 0 && - ( -
- -
-
- {searchConfig('isCelsius', true) - ? {weather.tempC}°C - : {weather.tempF}°F - } - {weather.cloud}% -
-
) - ) - } + {isLoading || + props.configLoading || + (props.config.WEATHER_API_KEY && weather.id > 0 && ( + +
+ +
+
+ {props.config.isCelsius ? ( + {weather.tempC}°C + ) : ( + {weather.tempF}°F + )} + {weather.cloud}% +
+
+ ))}
- ) -} + ); +}; const mapStateToProps = (state: GlobalState) => { return { configLoading: state.config.loading, - config: state.config.config - } -} + config: state.config.config, + }; +}; -export default connect(mapStateToProps)(WeatherWidget); \ No newline at end of file +export default connect(mapStateToProps)(WeatherWidget); diff --git a/client/src/interfaces/Config.ts b/client/src/interfaces/Config.ts index 281402ce..d0152c55 100644 --- a/client/src/interfaces/Config.ts +++ b/client/src/interfaces/Config.ts @@ -1,8 +1,22 @@ -import { Model } from './'; - -export interface Config extends Model { - key: string; - value: string; - valueType: string; - isLocked: boolean; -} \ No newline at end of file +export interface Config { + WEATHER_API_KEY: string; + lat: number; + long: number; + isCelsius: boolean; + customTitle: string; + pinAppsByDefault: boolean; + pinCategoriesByDefault: boolean; + hideHeader: boolean; + useOrdering: string; + appsSameTab: boolean; + bookmarksSameTab: boolean; + searchSameTab: boolean; + hideApps: boolean; + hideCategories: boolean; + hideSearch: boolean; + defaultSearchProvider: string; + dockerApps: boolean; + dockerHost: string; + kubernetesApps: boolean; + unpinStoppedApps: boolean; +} diff --git a/client/src/interfaces/Forms.ts b/client/src/interfaces/Forms.ts index 9b195da0..9123d628 100644 --- a/client/src/interfaces/Forms.ts +++ b/client/src/interfaces/Forms.ts @@ -2,30 +2,27 @@ export interface WeatherForm { WEATHER_API_KEY: string; lat: number; long: number; - isCelsius: number; + isCelsius: boolean; } export interface SearchForm { - hideSearch: number; + hideSearch: boolean; defaultSearchProvider: string; - searchSameTab: number; + searchSameTab: boolean; } -export interface SettingsForm { +export interface OtherSettingsForm { customTitle: string; - pinAppsByDefault: number; - pinCategoriesByDefault: number; - hideHeader: number; - hideApps: number; - hideCategories: number; - // hideSearch: number; - // defaultSearchProvider: string; + pinAppsByDefault: boolean; + pinCategoriesByDefault: boolean; + hideHeader: boolean; + hideApps: boolean; + hideCategories: boolean; useOrdering: string; - appsSameTab: number; - bookmarksSameTab: number; - // searchSameTab: number; - dockerApps: number; + appsSameTab: boolean; + bookmarksSameTab: boolean; + dockerApps: boolean; dockerHost: string; - kubernetesApps: number; - unpinStoppedApps: number; + kubernetesApps: boolean; + unpinStoppedApps: boolean; } diff --git a/client/src/utility/index.ts b/client/src/utility/index.ts index caff9c3a..ad080422 100644 --- a/client/src/utility/index.ts +++ b/client/src/utility/index.ts @@ -1,7 +1,8 @@ export * from './iconParser'; export * from './urlParser'; -export * from './searchConfig'; export * from './checkVersion'; export * from './sortData'; export * from './searchParser'; export * from './redirectUrl'; +export * from './templateObjects'; +export * from './inputHandler'; diff --git a/client/src/utility/inputHandler.ts b/client/src/utility/inputHandler.ts new file mode 100644 index 00000000..98e805a8 --- /dev/null +++ b/client/src/utility/inputHandler.ts @@ -0,0 +1,39 @@ +import { ChangeEvent, SetStateAction } from 'react'; + +type Event = ChangeEvent; + +interface Options { + isNumber?: boolean; + isBool?: boolean; +} + +interface Params { + e: Event; + options?: Options; + setStateHandler: (v: SetStateAction) => void; + state: T; +} + +export const inputHandler = (params: Params): void => { + const { e, options, setStateHandler, state } = params; + + const rawValue = e.target.value; + let value: string | number | boolean = e.target.value; + + if (options) { + const { isNumber = false, isBool = false } = options; + + if (isNumber) { + value = parseFloat(rawValue); + } + + if (isBool) { + value = !!parseInt(rawValue); + } + } + + setStateHandler({ + ...state, + [e.target.name]: value, + }); +}; diff --git a/client/src/utility/searchConfig.ts b/client/src/utility/searchConfig.ts deleted file mode 100644 index 4e46091b..00000000 --- a/client/src/utility/searchConfig.ts +++ /dev/null @@ -1,24 +0,0 @@ -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; -} \ No newline at end of file diff --git a/client/src/utility/searchParser.ts b/client/src/utility/searchParser.ts index e14617cf..cff9bfb6 100644 --- a/client/src/utility/searchParser.ts +++ b/client/src/utility/searchParser.ts @@ -1,7 +1,6 @@ import { queries } from './searchQueries.json'; import { Query, SearchResult } from '../interfaces'; import { store } from '../store/store'; -import { searchConfig } from '.'; export const searchParser = (searchQuery: string): SearchResult => { const result: SearchResult = { @@ -16,7 +15,7 @@ export const searchParser = (searchQuery: string): SearchResult => { }, }; - const customQueries = store.getState().config.customQueries; + const { customQueries, config } = store.getState().config; // Check if url or ip was passed const urlRegex = @@ -27,9 +26,7 @@ export const searchParser = (searchQuery: string): SearchResult => { // Match prefix and query const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i); - const prefix = splitQuery - ? splitQuery[1] - : searchConfig('defaultSearchProvider', 'l'); + const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider; const search = splitQuery ? encodeURIComponent(splitQuery[2]) @@ -47,7 +44,7 @@ export const searchParser = (searchQuery: string): SearchResult => { if (prefix === 'l') { result.isLocal = true; } else { - result.sameTab = searchConfig('searchSameTab', false); + result.sameTab = config.searchSameTab; } return result; diff --git a/client/src/utility/templateObjects/configTemplate.ts b/client/src/utility/templateObjects/configTemplate.ts new file mode 100644 index 00000000..bbc79981 --- /dev/null +++ b/client/src/utility/templateObjects/configTemplate.ts @@ -0,0 +1,24 @@ +import { Config } from '../../interfaces'; + +export const configTemplate: Config = { + WEATHER_API_KEY: '', + lat: 0, + long: 0, + isCelsius: true, + customTitle: 'Flame', + pinAppsByDefault: true, + pinCategoriesByDefault: true, + hideHeader: false, + useOrdering: 'createdAt', + appsSameTab: false, + bookmarksSameTab: false, + searchSameTab: false, + hideApps: false, + hideCategories: false, + hideSearch: false, + defaultSearchProvider: 'l', + dockerApps: false, + dockerHost: 'localhost', + kubernetesApps: false, + unpinStoppedApps: false, +}; diff --git a/client/src/utility/templateObjects/index.ts b/client/src/utility/templateObjects/index.ts new file mode 100644 index 00000000..3f2d57c8 --- /dev/null +++ b/client/src/utility/templateObjects/index.ts @@ -0,0 +1,2 @@ +export * from './configTemplate'; +export * from './settingsTemplate'; diff --git a/client/src/utility/templateObjects/settingsTemplate.ts b/client/src/utility/templateObjects/settingsTemplate.ts new file mode 100644 index 00000000..674931b7 --- /dev/null +++ b/client/src/utility/templateObjects/settingsTemplate.ts @@ -0,0 +1,30 @@ +import { OtherSettingsForm, SearchForm, WeatherForm } from '../../interfaces'; + +export const otherSettingsTemplate: OtherSettingsForm = { + customTitle: document.title, + pinAppsByDefault: true, + pinCategoriesByDefault: true, + hideHeader: false, + hideApps: false, + hideCategories: false, + useOrdering: 'createdAt', + appsSameTab: false, + bookmarksSameTab: false, + dockerApps: true, + dockerHost: 'localhost', + kubernetesApps: true, + unpinStoppedApps: true, +}; + +export const weatherSettingsTemplate: WeatherForm = { + WEATHER_API_KEY: '', + lat: 0, + long: 0, + isCelsius: true, +}; + +export const searchSettingsTemplate: SearchForm = { + hideSearch: false, + searchSameTab: false, + defaultSearchProvider: 'l', +}; From cfb471e578ed09bad28684dcb1a4cbf1ace4bf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Fri, 22 Oct 2021 14:00:38 +0200 Subject: [PATCH 06/88] Changed config api. Split config controllers into separate files. Split bookmarks controllers into separate files --- CHANGELOG.md | 1 + client/src/store/actions/app.ts | 176 ++++---- client/src/store/actions/bookmark.ts | 446 +++++++++++---------- client/src/store/actions/config.ts | 11 +- client/src/store/reducers/config.ts | 5 +- controllers/apps/docker/useDocker.js | 8 +- controllers/bookmark.js | 112 ------ controllers/bookmarks/createBookmark.js | 27 ++ controllers/bookmarks/deleteBookmark.js | 18 + controllers/bookmarks/getAllBookmarks.js | 19 + controllers/bookmarks/getSingleBookmark.js | 28 ++ controllers/bookmarks/index.js | 7 + controllers/bookmarks/updateBookmark.js | 39 ++ controllers/category.js | 12 +- controllers/config.js | 177 -------- controllers/config/getCSS.js | 18 + controllers/config/getConfig.js | 16 + controllers/config/index.js | 6 + controllers/config/updateCSS.js | 24 ++ controllers/config/updateConfig.js | 24 ++ middleware/multer.js | 2 +- routes/bookmark.js | 17 +- routes/config.js | 18 +- 23 files changed, 594 insertions(+), 617 deletions(-) delete mode 100644 controllers/bookmark.js create mode 100644 controllers/bookmarks/createBookmark.js create mode 100644 controllers/bookmarks/deleteBookmark.js create mode 100644 controllers/bookmarks/getAllBookmarks.js create mode 100644 controllers/bookmarks/getSingleBookmark.js create mode 100644 controllers/bookmarks/index.js create mode 100644 controllers/bookmarks/updateBookmark.js delete mode 100644 controllers/config.js create mode 100644 controllers/config/getCSS.js create mode 100644 controllers/config/getConfig.js create mode 100644 controllers/config/index.js create mode 100644 controllers/config/updateCSS.js create mode 100644 controllers/config/updateConfig.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 54d5274b..5b91cc2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ### v1.7.1 (TBA) - Fixed search action not being triggered by Numpad Enter - Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118)) +- Performance improvements ### v1.7.0 (2021-10-11) - Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67)) diff --git a/client/src/store/actions/app.ts b/client/src/store/actions/app.ts index 3a8e7d54..b33a78bc 100644 --- a/client/src/store/actions/app.ts +++ b/client/src/store/actions/app.ts @@ -5,14 +5,17 @@ import { App, ApiResponse, NewApp, Config } from '../../interfaces'; import { CreateNotificationAction } from './notification'; export interface GetAppsAction { - type: ActionTypes.getApps | ActionTypes.getAppsSuccess | ActionTypes.getAppsError; + type: + | ActionTypes.getApps + | ActionTypes.getAppsSuccess + | ActionTypes.getAppsError; payload: T; } export const getApps = () => async (dispatch: Dispatch) => { dispatch>({ type: ActionTypes.getApps, - payload: undefined + payload: undefined, }); try { @@ -20,12 +23,12 @@ export const getApps = () => async (dispatch: Dispatch) => { dispatch>({ type: ActionTypes.getAppsSuccess, - payload: res.data.data - }) + payload: res.data.data, + }); } catch (err) { console.log(err); } -} +}; export interface PinAppAction { type: ActionTypes.pinApp; @@ -35,59 +38,64 @@ export interface PinAppAction { export const pinApp = (app: App) => async (dispatch: Dispatch) => { try { const { id, isPinned, name } = app; - const res = await axios.put>(`/api/apps/${id}`, { isPinned: !isPinned }); + const res = await axios.put>(`/api/apps/${id}`, { + isPinned: !isPinned, + }); - const status = isPinned ? 'unpinned from Homescreen' : 'pinned to Homescreen'; + const status = isPinned + ? 'unpinned from Homescreen' + : 'pinned to Homescreen'; dispatch({ type: ActionTypes.createNotification, payload: { title: 'Success', - message: `App ${name} ${status}` - } - }) + message: `App ${name} ${status}`, + }, + }); dispatch({ type: ActionTypes.pinApp, - payload: res.data.data - }) + payload: res.data.data, + }); } catch (err) { console.log(err); } -} +}; export interface AddAppAction { type: ActionTypes.addAppSuccess; payload: App; } -export const addApp = (formData: NewApp | FormData) => async (dispatch: Dispatch) => { - try { - const res = await axios.post>('/api/apps', formData); - - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `App added` - } - }) - - await dispatch({ - type: ActionTypes.addAppSuccess, - payload: res.data.data - }) - - // Sort apps - dispatch(sortApps()) - } catch (err) { - console.log(err); - } -} +export const addApp = + (formData: NewApp | FormData) => async (dispatch: Dispatch) => { + try { + const res = await axios.post>('/api/apps', formData); + + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: 'Success', + message: `App added`, + }, + }); + + await dispatch({ + type: ActionTypes.addAppSuccess, + payload: res.data.data, + }); + + // Sort apps + dispatch(sortApps()); + } catch (err) { + console.log(err); + } + }; export interface DeleteAppAction { - type: ActionTypes.deleteApp, - payload: number + type: ActionTypes.deleteApp; + payload: number; } export const deleteApp = (id: number) => async (dispatch: Dispatch) => { @@ -98,79 +106,85 @@ export const deleteApp = (id: number) => async (dispatch: Dispatch) => { type: ActionTypes.createNotification, payload: { title: 'Success', - message: 'App deleted' - } - }) + message: 'App deleted', + }, + }); dispatch({ type: ActionTypes.deleteApp, - payload: id - }) + payload: id, + }); } catch (err) { console.log(err); } -} +}; export interface UpdateAppAction { type: ActionTypes.updateApp; payload: App; } -export const updateApp = (id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => { - try { - const res = await axios.put>(`/api/apps/${id}`, formData); - - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `App updated` - } - }) - - await dispatch({ - type: ActionTypes.updateApp, - payload: res.data.data - }) - - // Sort apps - dispatch(sortApps()) - } catch (err) { - console.log(err); - } -} +export const updateApp = + (id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/apps/${id}`, + formData + ); + + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: 'Success', + message: `App updated`, + }, + }); + + await dispatch({ + type: ActionTypes.updateApp, + payload: res.data.data, + }); + + // Sort apps + dispatch(sortApps()); + } catch (err) { + console.log(err); + } + }; export interface ReorderAppsAction { type: ActionTypes.reorderApps; - payload: App[] + payload: App[]; } interface ReorderQuery { apps: { id: number; orderId: number; - }[] + }[]; } export const reorderApps = (apps: App[]) => async (dispatch: Dispatch) => { try { - const updateQuery: ReorderQuery = { apps: [] } + const updateQuery: ReorderQuery = { apps: [] }; - apps.forEach((app, index) => updateQuery.apps.push({ - id: app.id, - orderId: index + 1 - })) + apps.forEach((app, index) => + updateQuery.apps.push({ + id: app.id, + orderId: index + 1, + }) + ); await axios.put>('/api/apps/0/reorder', updateQuery); dispatch({ type: ActionTypes.reorderApps, - payload: apps - }) + payload: apps, + }); } catch (err) { console.log(err); } -} +}; export interface SortAppsAction { type: ActionTypes.sortApps; @@ -179,13 +193,13 @@ export interface SortAppsAction { export const sortApps = () => async (dispatch: Dispatch) => { try { - const res = await axios.get>('/api/config/useOrdering'); + const res = await axios.get>('/api/config'); dispatch({ type: ActionTypes.sortApps, - payload: res.data.data.value - }) + payload: res.data.data.useOrdering, + }); } catch (err) { console.log(err); } -} \ No newline at end of file +}; diff --git a/client/src/store/actions/bookmark.ts b/client/src/store/actions/bookmark.ts index b4b5831b..6d6fdf55 100644 --- a/client/src/store/actions/bookmark.ts +++ b/client/src/store/actions/bookmark.ts @@ -1,133 +1,157 @@ import axios from 'axios'; import { Dispatch } from 'redux'; import { ActionTypes } from './actionTypes'; -import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark, Config } from '../../interfaces'; +import { + Category, + ApiResponse, + NewCategory, + Bookmark, + NewBookmark, + Config, +} from '../../interfaces'; import { CreateNotificationAction } from './notification'; /** * GET CATEGORIES */ export interface GetCategoriesAction { - type: ActionTypes.getCategories | ActionTypes.getCategoriesSuccess | ActionTypes.getCategoriesError; + type: + | ActionTypes.getCategories + | ActionTypes.getCategoriesSuccess + | ActionTypes.getCategoriesError; payload: T; } export const getCategories = () => async (dispatch: Dispatch) => { dispatch>({ type: ActionTypes.getCategories, - payload: undefined - }) + payload: undefined, + }); try { const res = await axios.get>('/api/categories'); dispatch>({ type: ActionTypes.getCategoriesSuccess, - payload: res.data.data - }) + payload: res.data.data, + }); } catch (err) { console.log(err); } -} +}; /** * ADD CATEGORY */ export interface AddCategoryAction { - type: ActionTypes.addCategory, - payload: Category + type: ActionTypes.addCategory; + payload: Category; } -export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch) => { - try { - const res = await axios.post>('/api/categories', formData); - - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `Category ${formData.name} created` - } - }) - - dispatch({ - type: ActionTypes.addCategory, - payload: res.data.data - }) +export const addCategory = + (formData: NewCategory) => async (dispatch: Dispatch) => { + try { + const res = await axios.post>( + '/api/categories', + formData + ); - dispatch(sortCategories()); - } catch (err) { - console.log(err); - } -} + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: 'Success', + message: `Category ${formData.name} created`, + }, + }); + + dispatch({ + type: ActionTypes.addCategory, + payload: res.data.data, + }); + + dispatch(sortCategories()); + } catch (err) { + console.log(err); + } + }; /** * ADD BOOKMARK */ export interface AddBookmarkAction { - type: ActionTypes.addBookmark, - payload: Bookmark + type: ActionTypes.addBookmark; + payload: Bookmark; } -export const addBookmark = (formData: NewBookmark | FormData) => async (dispatch: Dispatch) => { - try { - const res = await axios.post>('/api/bookmarks', formData); +export const addBookmark = + (formData: NewBookmark | FormData) => async (dispatch: Dispatch) => { + try { + const res = await axios.post>( + '/api/bookmarks', + formData + ); - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `Bookmark created` - } - }) + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: 'Success', + message: `Bookmark created`, + }, + }); - dispatch({ - type: ActionTypes.addBookmark, - payload: res.data.data - }) - } catch (err) { - console.log(err); - } -} + dispatch({ + type: ActionTypes.addBookmark, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; /** * PIN CATEGORY */ export interface PinCategoryAction { - type: ActionTypes.pinCategory, - payload: Category + type: ActionTypes.pinCategory; + payload: Category; } -export const pinCategory = (category: Category) => async (dispatch: Dispatch) => { - try { - const { id, isPinned, name } = category; - const res = await axios.put>(`/api/categories/${id}`, { isPinned: !isPinned }); - - const status = isPinned ? 'unpinned from Homescreen' : 'pinned to Homescreen'; - - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `Category ${name} ${status}` - } - }) - - dispatch({ - type: ActionTypes.pinCategory, - payload: res.data.data - }) - } catch (err) { - console.log(err); - } -} +export const pinCategory = + (category: Category) => async (dispatch: Dispatch) => { + try { + const { id, isPinned, name } = category; + const res = await axios.put>( + `/api/categories/${id}`, + { isPinned: !isPinned } + ); + + const status = isPinned + ? 'unpinned from Homescreen' + : 'pinned to Homescreen'; + + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: 'Success', + message: `Category ${name} ${status}`, + }, + }); + + dispatch({ + type: ActionTypes.pinCategory, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; /** * DELETE CATEGORY */ export interface DeleteCategoryAction { - type: ActionTypes.deleteCategory, - payload: number + type: ActionTypes.deleteCategory; + payload: number; } export const deleteCategory = (id: number) => async (dispatch: Dispatch) => { @@ -138,141 +162,151 @@ export const deleteCategory = (id: number) => async (dispatch: Dispatch) => { type: ActionTypes.createNotification, payload: { title: 'Success', - message: `Category deleted` - } - }) + message: `Category deleted`, + }, + }); dispatch({ type: ActionTypes.deleteCategory, - payload: id - }) + payload: id, + }); } catch (err) { console.log(err); } -} +}; /** * UPDATE CATEGORY */ export interface UpdateCategoryAction { - type: ActionTypes.updateCategory, - payload: Category + type: ActionTypes.updateCategory; + payload: Category; } -export const updateCategory = (id: number, formData: NewCategory) => async (dispatch: Dispatch) => { - try { - const res = await axios.put>(`/api/categories/${id}`, formData); - - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `Category ${formData.name} updated` - } - }) - - dispatch({ - type: ActionTypes.updateCategory, - payload: res.data.data - }) +export const updateCategory = + (id: number, formData: NewCategory) => async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/categories/${id}`, + formData + ); - dispatch(sortCategories()); - } catch (err) { - console.log(err); - } -} + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: 'Success', + message: `Category ${formData.name} updated`, + }, + }); + + dispatch({ + type: ActionTypes.updateCategory, + payload: res.data.data, + }); + + dispatch(sortCategories()); + } catch (err) { + console.log(err); + } + }; /** * DELETE BOOKMARK */ export interface DeleteBookmarkAction { - type: ActionTypes.deleteBookmark, + type: ActionTypes.deleteBookmark; payload: { - bookmarkId: number, - categoryId: number - } + bookmarkId: number; + categoryId: number; + }; } -export const deleteBookmark = (bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => { - try { - await axios.delete>(`/api/bookmarks/${bookmarkId}`); +export const deleteBookmark = + (bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => { + try { + await axios.delete>(`/api/bookmarks/${bookmarkId}`); - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: 'Bookmark deleted' - } - }) + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: 'Success', + message: 'Bookmark deleted', + }, + }); - dispatch({ - type: ActionTypes.deleteBookmark, - payload: { - bookmarkId, - categoryId - } - }) - } catch (err) { - console.log(err); - } -} + dispatch({ + type: ActionTypes.deleteBookmark, + payload: { + bookmarkId, + categoryId, + }, + }); + } catch (err) { + console.log(err); + } + }; /** * UPDATE BOOKMARK */ export interface UpdateBookmarkAction { - type: ActionTypes.updateBookmark, - payload: Bookmark + type: ActionTypes.updateBookmark; + payload: Bookmark; } -export const updateBookmark = ( - bookmarkId: number, - formData: NewBookmark | FormData, - category: { - prev: number, - curr: number - } -) => async (dispatch: Dispatch) => { - try { - const res = await axios.put>(`/api/bookmarks/${bookmarkId}`, formData); - - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `Bookmark updated` - } - }) - - // Check if category was changed - const categoryWasChanged = category.curr !== category.prev; - - if (categoryWasChanged) { - // Delete bookmark from old category - dispatch({ - type: ActionTypes.deleteBookmark, +export const updateBookmark = + ( + bookmarkId: number, + formData: NewBookmark | FormData, + category: { + prev: number; + curr: number; + } + ) => + async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/bookmarks/${bookmarkId}`, + formData + ); + + dispatch({ + type: ActionTypes.createNotification, payload: { - bookmarkId, - categoryId: category.prev - } - }) - - // Add bookmark to the new category - dispatch({ - type: ActionTypes.addBookmark, - payload: res.data.data - }) - } else { - // Else update only name/url/icon - dispatch({ - type: ActionTypes.updateBookmark, - payload: res.data.data - }) + title: 'Success', + message: `Bookmark updated`, + }, + }); + + // Check if category was changed + const categoryWasChanged = category.curr !== category.prev; + + if (categoryWasChanged) { + // Delete bookmark from old category + dispatch({ + type: ActionTypes.deleteBookmark, + payload: { + bookmarkId, + categoryId: category.prev, + }, + }); + + // Add bookmark to the new category + dispatch({ + type: ActionTypes.addBookmark, + payload: res.data.data, + }); + } else { + // Else update only name/url/icon + dispatch({ + type: ActionTypes.updateBookmark, + payload: res.data.data, + }); + } + } catch (err) { + console.log(err); } - } catch (err) { - console.log(err); - } -} + }; /** * SORT CATEGORIES @@ -284,16 +318,16 @@ export interface SortCategoriesAction { export const sortCategories = () => async (dispatch: Dispatch) => { try { - const res = await axios.get>('/api/config/useOrdering'); + const res = await axios.get>('/api/config'); dispatch({ type: ActionTypes.sortCategories, - payload: res.data.data.value - }) + payload: res.data.data.useOrdering, + }); } catch (err) { console.log(err); } -} +}; /** * REORDER CATEGORIES @@ -307,25 +341,31 @@ 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>('/api/categories/0/reorder', updateQuery); - - dispatch({ - type: ActionTypes.reorderCategories, - payload: categories - }) - } catch (err) { - console.log(err); - } -} \ No newline at end of file +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>( + '/api/categories/0/reorder', + updateQuery + ); + + dispatch({ + type: ActionTypes.reorderCategories, + payload: categories, + }); + } catch (err) { + console.log(err); + } + }; diff --git a/client/src/store/actions/config.ts b/client/src/store/actions/config.ts index 29c5186b..8b1ef5a9 100644 --- a/client/src/store/actions/config.ts +++ b/client/src/store/actions/config.ts @@ -3,16 +3,15 @@ import { Dispatch } from 'redux'; import { ActionTypes } from './actionTypes'; import { Config, ApiResponse, Query } from '../../interfaces'; import { CreateNotificationAction } from './notification'; -import { searchConfig } from '../../utility'; export interface GetConfigAction { type: ActionTypes.getConfig; - payload: Config[]; + payload: Config; } export const getConfig = () => async (dispatch: Dispatch) => { try { - const res = await axios.get>('/api/config'); + const res = await axios.get>('/api/config'); dispatch({ type: ActionTypes.getConfig, @@ -20,7 +19,7 @@ export const getConfig = () => async (dispatch: Dispatch) => { }); // Set custom page title if set - document.title = searchConfig('customTitle', 'Flame'); + document.title = res.data.data.customTitle; } catch (err) { console.log(err); } @@ -28,12 +27,12 @@ export const getConfig = () => async (dispatch: Dispatch) => { export interface UpdateConfigAction { type: ActionTypes.updateConfig; - payload: Config[]; + payload: Config; } export const updateConfig = (formData: any) => async (dispatch: Dispatch) => { try { - const res = await axios.put>('/api/config', formData); + const res = await axios.put>('/api/config', formData); dispatch({ type: ActionTypes.createNotification, diff --git a/client/src/store/reducers/config.ts b/client/src/store/reducers/config.ts index ae2699ec..c0ece136 100644 --- a/client/src/store/reducers/config.ts +++ b/client/src/store/reducers/config.ts @@ -1,15 +1,16 @@ import { ActionTypes, Action } from '../actions'; import { Config, Query } from '../../interfaces'; +import { configTemplate } from '../../utility'; export interface State { loading: boolean; - config: Config[]; + config: Config; customQueries: Query[]; } const initialState: State = { loading: true, - config: [], + config: configTemplate, customQueries: [], }; diff --git a/controllers/apps/docker/useDocker.js b/controllers/apps/docker/useDocker.js index fcc43792..88ecb3eb 100644 --- a/controllers/apps/docker/useDocker.js +++ b/controllers/apps/docker/useDocker.js @@ -1,8 +1,8 @@ -const App = require('../../models/App'); +const App = require('../../../models/App'); const axios = require('axios'); -const Logger = require('../../utils/Logger'); +const Logger = require('../../../utils/Logger'); const logger = new Logger(); -const loadConfig = require('../../utils/loadConfig'); +const loadConfig = require('../../../utils/loadConfig'); const useDocker = async (apps) => { const { @@ -50,7 +50,7 @@ const useDocker = async (apps) => { for (const container of containers) { let labels = container.Labels; - // todo + // Traefik labels for URL configuration if (!('flame.url' in labels)) { for (const label of Object.keys(labels)) { if (/^traefik.*.frontend.rule/.test(label)) { diff --git a/controllers/bookmark.js b/controllers/bookmark.js deleted file mode 100644 index e745d4dc..00000000 --- a/controllers/bookmark.js +++ /dev/null @@ -1,112 +0,0 @@ -const asyncWrapper = require('../middleware/asyncWrapper'); -const ErrorResponse = require('../utils/ErrorResponse'); -const Bookmark = require('../models/Bookmark'); -const { Sequelize } = require('sequelize'); - -// @desc Create new bookmark -// @route POST /api/bookmarks -// @access Public -exports.createBookmark = asyncWrapper(async (req, res, next) => { - let bookmark; - - let _body = { - ...req.body, - categoryId: parseInt(req.body.categoryId), - }; - - if (req.file) { - _body.icon = req.file.filename; - } - - bookmark = await Bookmark.create(_body); - - res.status(201).json({ - success: true, - data: bookmark, - }); -}); - -// @desc Get all bookmarks -// @route GET /api/bookmarks -// @access Public -exports.getBookmarks = asyncWrapper(async (req, res, next) => { - const bookmarks = await Bookmark.findAll({ - order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']], - }); - - res.status(200).json({ - success: true, - data: bookmarks, - }); -}); - -// @desc Get single bookmark -// @route GET /api/bookmarks/:id -// @access Public -exports.getBookmark = asyncWrapper(async (req, res, next) => { - const bookmark = await Bookmark.findOne({ - where: { id: req.params.id }, - }); - - if (!bookmark) { - return next( - new ErrorResponse( - `Bookmark with id of ${req.params.id} was not found`, - 404 - ) - ); - } - - res.status(200).json({ - success: true, - data: bookmark, - }); -}); - -// @desc Update bookmark -// @route PUT /api/bookmarks/:id -// @access Public -exports.updateBookmark = asyncWrapper(async (req, res, next) => { - let bookmark = await Bookmark.findOne({ - where: { id: req.params.id }, - }); - - if (!bookmark) { - return next( - new ErrorResponse( - `Bookmark with id of ${req.params.id} was not found`, - 404 - ) - ); - } - - let _body = { - ...req.body, - categoryId: parseInt(req.body.categoryId), - }; - - if (req.file) { - _body.icon = req.file.filename; - } - - bookmark = await bookmark.update(_body); - - res.status(200).json({ - success: true, - data: bookmark, - }); -}); - -// @desc Delete bookmark -// @route DELETE /api/bookmarks/:id -// @access Public -exports.deleteBookmark = asyncWrapper(async (req, res, next) => { - await Bookmark.destroy({ - where: { id: req.params.id }, - }); - - res.status(200).json({ - success: true, - data: {}, - }); -}); diff --git a/controllers/bookmarks/createBookmark.js b/controllers/bookmarks/createBookmark.js new file mode 100644 index 00000000..2292c50b --- /dev/null +++ b/controllers/bookmarks/createBookmark.js @@ -0,0 +1,27 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const Bookmark = require('../../models/Bookmark'); + +// @desc Create new bookmark +// @route POST /api/bookmarks +// @access Public +const createBookmark = asyncWrapper(async (req, res, next) => { + let bookmark; + + let _body = { + ...req.body, + categoryId: parseInt(req.body.categoryId), + }; + + if (req.file) { + _body.icon = req.file.filename; + } + + bookmark = await Bookmark.create(_body); + + res.status(201).json({ + success: true, + data: bookmark, + }); +}); + +module.exports = createBookmark; diff --git a/controllers/bookmarks/deleteBookmark.js b/controllers/bookmarks/deleteBookmark.js new file mode 100644 index 00000000..c511a305 --- /dev/null +++ b/controllers/bookmarks/deleteBookmark.js @@ -0,0 +1,18 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const Bookmark = require('../../models/Bookmark'); + +// @desc Delete bookmark +// @route DELETE /api/bookmarks/:id +// @access Public +const deleteBookmark = asyncWrapper(async (req, res, next) => { + await Bookmark.destroy({ + where: { id: req.params.id }, + }); + + res.status(200).json({ + success: true, + data: {}, + }); +}); + +module.exports = deleteBookmark; diff --git a/controllers/bookmarks/getAllBookmarks.js b/controllers/bookmarks/getAllBookmarks.js new file mode 100644 index 00000000..c4d8dde9 --- /dev/null +++ b/controllers/bookmarks/getAllBookmarks.js @@ -0,0 +1,19 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const Bookmark = require('../../models/Bookmark'); +const { Sequelize } = require('sequelize'); + +// @desc Get all bookmarks +// @route GET /api/bookmarks +// @access Public +const getAllBookmarks = asyncWrapper(async (req, res, next) => { + const bookmarks = await Bookmark.findAll({ + order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']], + }); + + res.status(200).json({ + success: true, + data: bookmarks, + }); +}); + +module.exports = getAllBookmarks; diff --git a/controllers/bookmarks/getSingleBookmark.js b/controllers/bookmarks/getSingleBookmark.js new file mode 100644 index 00000000..18c0cbff --- /dev/null +++ b/controllers/bookmarks/getSingleBookmark.js @@ -0,0 +1,28 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const ErrorResponse = require('../../utils/ErrorResponse'); +const Bookmark = require('../../models/Bookmark'); + +// @desc Get single bookmark +// @route GET /api/bookmarks/:id +// @access Public +const getSingleBookmark = asyncWrapper(async (req, res, next) => { + const bookmark = await Bookmark.findOne({ + where: { id: req.params.id }, + }); + + if (!bookmark) { + return next( + new ErrorResponse( + `Bookmark with the id of ${req.params.id} was not found`, + 404 + ) + ); + } + + res.status(200).json({ + success: true, + data: bookmark, + }); +}); + +module.exports = getSingleBookmark; diff --git a/controllers/bookmarks/index.js b/controllers/bookmarks/index.js new file mode 100644 index 00000000..f1ef588b --- /dev/null +++ b/controllers/bookmarks/index.js @@ -0,0 +1,7 @@ +module.exports = { + createBookmark: require('./createBookmark'), + getAllBookmarks: require('./getAllBookmarks'), + getSingleBookmark: require('./getSingleBookmark'), + updateBookmark: require('./updateBookmark'), + deleteBookmark: require('./deleteBookmark'), +}; diff --git a/controllers/bookmarks/updateBookmark.js b/controllers/bookmarks/updateBookmark.js new file mode 100644 index 00000000..778d2eb5 --- /dev/null +++ b/controllers/bookmarks/updateBookmark.js @@ -0,0 +1,39 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const ErrorResponse = require('../../utils/ErrorResponse'); +const Bookmark = require('../../models/Bookmark'); + +// @desc Update bookmark +// @route PUT /api/bookmarks/:id +// @access Public +const updateBookmark = asyncWrapper(async (req, res, next) => { + let bookmark = await Bookmark.findOne({ + where: { id: req.params.id }, + }); + + if (!bookmark) { + return next( + new ErrorResponse( + `Bookmark with id of ${req.params.id} was not found`, + 404 + ) + ); + } + + let _body = { + ...req.body, + categoryId: parseInt(req.body.categoryId), + }; + + if (req.file) { + _body.icon = req.file.filename; + } + + bookmark = await bookmark.update(_body); + + res.status(200).json({ + success: true, + data: bookmark, + }); +}); + +module.exports = updateBookmark; diff --git a/controllers/category.js b/controllers/category.js index 0f1af58b..557c1a1d 100644 --- a/controllers/category.js +++ b/controllers/category.js @@ -4,15 +4,13 @@ const Category = require('../models/Category'); const Bookmark = require('../models/Bookmark'); const Config = require('../models/Config'); const { Sequelize } = require('sequelize'); +const loadConfig = require('../utils/loadConfig'); // @desc Create new category // @route POST /api/categories // @access Public exports.createCategory = asyncWrapper(async (req, res, next) => { - // Get config from database - const pinCategories = await Config.findOne({ - where: { key: 'pinCategoriesByDefault' }, - }); + const { pinCategoriesByDefault: pinCategories } = await loadConfig(); let category; @@ -37,12 +35,8 @@ exports.createCategory = asyncWrapper(async (req, res, next) => { // @route GET /api/categories // @access Public exports.getCategories = asyncWrapper(async (req, res, next) => { - // Get config from database - const useOrdering = await Config.findOne({ - where: { key: 'useOrdering' }, - }); + const { useOrdering: orderType } = await loadConfig(); - const orderType = useOrdering ? useOrdering.value : 'createdAt'; let categories; if (orderType == 'name') { diff --git a/controllers/config.js b/controllers/config.js deleted file mode 100644 index e5290aa2..00000000 --- a/controllers/config.js +++ /dev/null @@ -1,177 +0,0 @@ -const asyncWrapper = require('../middleware/asyncWrapper'); -const ErrorResponse = require('../utils/ErrorResponse'); -const Config = require('../models/Config'); -const { Op } = require('sequelize'); -const File = require('../utils/File'); -const { join } = require('path'); -const fs = require('fs'); - -// @desc Insert new key:value pair -// @route POST /api/config -// @access Public -exports.createPair = asyncWrapper(async (req, res, next) => { - const pair = await Config.create(req.body); - - res.status(201).json({ - success: true, - data: pair, - }); -}); - -// @desc Get all key:value pairs -// @route GET /api/config -// @route GET /api/config?keys=foo,bar,baz -// @access Public -exports.getAllPairs = asyncWrapper(async (req, res, next) => { - let pairs; - - if (req.query.keys) { - // Check for specific keys to get in a single query - const keys = req.query.keys.split(',').map((key) => { - return { key }; - }); - - pairs = await Config.findAll({ - where: { - [Op.or]: keys, - }, - }); - } else { - // Else get all - pairs = await Config.findAll(); - } - - res.status(200).json({ - success: true, - data: pairs, - }); -}); - -// @desc Get single key:value pair -// @route GET /api/config/:key -// @access Public -exports.getSinglePair = asyncWrapper(async (req, res, next) => { - const pair = await Config.findOne({ - where: { key: req.params.key }, - }); - - if (!pair) { - return next(new ErrorResponse(`Key ${req.params.key} was not found`, 404)); - } - - res.status(200).json({ - success: true, - data: pair, - }); -}); - -// @desc Update value -// @route PUT /api/config/:key -// @access Public -exports.updateValue = asyncWrapper(async (req, res, next) => { - let pair = await Config.findOne({ - where: { key: req.params.key }, - }); - - if (!pair) { - return next(new ErrorResponse(`Key ${req.params.key} was not found`, 404)); - } - - if (pair.isLocked) { - return next( - new ErrorResponse( - `Value of key ${req.params.key} is locked and can not be changed`, - 400 - ) - ); - } - - pair = await pair.update({ ...req.body }); - - res.status(200).json({ - success: true, - data: pair, - }); -}); - -// @desc Update multiple values -// @route PUT /api/config/ -// @access Public -exports.updateValues = asyncWrapper(async (req, res, next) => { - Object.entries(req.body).forEach(async ([key, value]) => { - await Config.update( - { value }, - { - where: { key }, - } - ); - }); - - const config = await Config.findAll(); - - res.status(200).send({ - success: true, - data: config, - }); -}); - -// @desc Delete key:value pair -// @route DELETE /api/config/:key -// @access Public -exports.deletePair = asyncWrapper(async (req, res, next) => { - const pair = await Config.findOne({ - where: { key: req.params.key }, - }); - - if (!pair) { - return next(new ErrorResponse(`Key ${req.params.key} was not found`, 404)); - } - - if (pair.isLocked) { - return next( - new ErrorResponse( - `Value of key ${req.params.key} is locked and can not be deleted`, - 400 - ) - ); - } - - 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, false); - - // Copy file to docker volume - fs.copyFileSync( - join(__dirname, '../public/flame.css'), - join(__dirname, '../data/flame.css') - ); - - res.status(200).json({ - success: true, - data: {}, - }); -}); diff --git a/controllers/config/getCSS.js b/controllers/config/getCSS.js new file mode 100644 index 00000000..db6b7836 --- /dev/null +++ b/controllers/config/getCSS.js @@ -0,0 +1,18 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); +const { join } = require('path'); + +// @desc Get custom CSS file +// @route GET /api/config/0/css +// @access Public +const 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, + }); +}); + +module.exports = getCSS; diff --git a/controllers/config/getConfig.js b/controllers/config/getConfig.js new file mode 100644 index 00000000..cb196f77 --- /dev/null +++ b/controllers/config/getConfig.js @@ -0,0 +1,16 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const loadConfig = require('../../utils/loadConfig'); + +// @desc Get config +// @route GET /api/config +// @access Public +const getConfig = asyncWrapper(async (req, res, next) => { + const config = await loadConfig(); + + res.status(200).json({ + success: true, + data: config, + }); +}); + +module.exports = getConfig; diff --git a/controllers/config/index.js b/controllers/config/index.js new file mode 100644 index 00000000..ae3c8288 --- /dev/null +++ b/controllers/config/index.js @@ -0,0 +1,6 @@ +module.exports = { + getCSS: require('./getCSS'), + updateCSS: require('./updateCSS'), + getConfig: require('./getConfig'), + updateConfig: require('./updateConfig'), +}; diff --git a/controllers/config/updateCSS.js b/controllers/config/updateCSS.js new file mode 100644 index 00000000..4deea766 --- /dev/null +++ b/controllers/config/updateCSS.js @@ -0,0 +1,24 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); +const { join } = require('path'); + +// @desc Update custom CSS file +// @route PUT /api/config/0/css +// @access Public +const updateCSS = asyncWrapper(async (req, res, next) => { + const file = new File(join(__dirname, '../../public/flame.css')); + file.write(req.body.styles, false); + + // Copy file to docker volume + fs.copyFileSync( + join(__dirname, '../../public/flame.css'), + join(__dirname, '../../data/flame.css') + ); + + res.status(200).json({ + success: true, + data: {}, + }); +}); + +module.exports = updateCSS; diff --git a/controllers/config/updateConfig.js b/controllers/config/updateConfig.js new file mode 100644 index 00000000..722f3343 --- /dev/null +++ b/controllers/config/updateConfig.js @@ -0,0 +1,24 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const loadConfig = require('../../utils/loadConfig'); +const { writeFile } = require('fs/promises'); + +// @desc Update config +// @route PUT /api/config/ +// @access Public +const updateConfig = asyncWrapper(async (req, res, next) => { + const existingConfig = await loadConfig(); + + const newConfig = { + ...existingConfig, + ...req.body, + }; + + await writeFile('data/config.json', JSON.stringify(newConfig)); + + res.status(200).send({ + success: true, + data: newConfig, + }); +}); + +module.exports = updateConfig; diff --git a/middleware/multer.js b/middleware/multer.js index bd493f57..806e5b47 100644 --- a/middleware/multer.js +++ b/middleware/multer.js @@ -11,7 +11,7 @@ const storage = multer.diskStorage({ }, filename: (req, file, cb) => { cb(null, Date.now() + '--' + file.originalname); - } + }, }); const supportedTypes = ['jpg', 'jpeg', 'png', 'svg', 'svg+xml']; diff --git a/routes/bookmark.js b/routes/bookmark.js index c5947380..f7e541b5 100644 --- a/routes/bookmark.js +++ b/routes/bookmark.js @@ -4,21 +4,18 @@ const upload = require('../middleware/multer'); const { createBookmark, - getBookmarks, - getBookmark, + getAllBookmarks, + getSingleBookmark, updateBookmark, - deleteBookmark -} = require('../controllers/bookmark'); + deleteBookmark, +} = require('../controllers/bookmarks'); -router - .route('/') - .post(upload, createBookmark) - .get(getBookmarks); +router.route('/').post(upload, createBookmark).get(getAllBookmarks); router .route('/:id') - .get(getBookmark) + .get(getSingleBookmark) .put(upload, updateBookmark) .delete(deleteBookmark); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/config.js b/routes/config.js index 8c9ac150..fbb632f3 100644 --- a/routes/config.js +++ b/routes/config.js @@ -2,20 +2,14 @@ const express = require('express'); const router = express.Router(); const { - createPair, - getAllPairs, - getSinglePair, - updateValue, - updateValues, - deletePair, - updateCss, - getCss, + getCSS, + updateCSS, + getConfig, + updateConfig, } = require('../controllers/config'); -router.route('/').post(createPair).get(getAllPairs).put(updateValues); +router.route('/').get(getConfig).put(updateConfig); -router.route('/:key').get(getSinglePair).put(updateValue).delete(deletePair); - -router.route('/0/css').get(getCss).put(updateCss); +router.route('/0/css').get(getCSS).put(updateCSS); module.exports = router; From 4ef9652ede50e9ac5efa6ec7097eb8979409c1a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Fri, 22 Oct 2021 15:51:11 +0200 Subject: [PATCH 07/88] Added option to change date formatting. Added shortcuts to clear search bar --- CHANGELOG.md | 3 ++ client/src/App.tsx | 2 +- .../src/components/Home/functions/dateTime.ts | 39 +++++++++++++++++-- client/src/components/SearchBar/SearchBar.tsx | 1 + .../Settings/OtherSettings/OtherSettings.tsx | 12 ++++++ client/src/interfaces/Config.ts | 1 + client/src/interfaces/Forms.ts | 1 + client/src/store/actions/config.ts | 6 +++ .../utility/templateObjects/configTemplate.ts | 1 + .../templateObjects/settingsTemplate.ts | 1 + controllers/category.js | 14 +++---- 11 files changed, 68 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b91cc2b..06f83ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ### v1.7.1 (TBA) - Fixed search action not being triggered by Numpad Enter +- Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92)) +- Added shortcuts (Esc and double click) to clear search bar ([#100](https://github.com/pawelmalak/flame/issues/100)) +- Added Traefik integration ([#102](https://github.com/pawelmalak/flame/issues/102)) - Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118)) - Performance improvements diff --git a/client/src/App.tsx b/client/src/App.tsx index 9311b4b7..3968bcd6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -16,7 +16,7 @@ import Settings from './components/Settings/Settings'; import Bookmarks from './components/Bookmarks/Bookmarks'; import NotificationCenter from './components/NotificationCenter/NotificationCenter'; -// Get config pairs from database +// Load config store.dispatch(getConfig()); // Set theme diff --git a/client/src/components/Home/functions/dateTime.ts b/client/src/components/Home/functions/dateTime.ts index 44cc5e18..ddcfc705 100644 --- a/client/src/components/Home/functions/dateTime.ts +++ b/client/src/components/Home/functions/dateTime.ts @@ -1,8 +1,39 @@ 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 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()}`; -} \ No newline at end of file + const useAmericanDate = localStorage.useAmericanDate === 'true'; + + if (!useAmericanDate) { + return `${days[now.getDay()]}, ${now.getDate()} ${ + months[now.getMonth()] + } ${now.getFullYear()}`; + } else { + return `${days[now.getDay()]}, ${ + months[now.getMonth()] + } ${now.getDate()} ${now.getFullYear()}`; + } +}; diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index 85175ff7..b6a981f3 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -72,6 +72,7 @@ const SearchBar = (props: ComponentProps): JSX.Element => { type="text" className={classes.SearchBar} onKeyUp={(e) => searchHandler(e)} + onDoubleClick={clearSearch} /> ); diff --git a/client/src/components/Settings/OtherSettings/OtherSettings.tsx b/client/src/components/Settings/OtherSettings/OtherSettings.tsx index 3d82fa44..6610b654 100644 --- a/client/src/components/Settings/OtherSettings/OtherSettings.tsx +++ b/client/src/components/Settings/OtherSettings/OtherSettings.tsx @@ -92,6 +92,18 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { onChange={(e) => inputChangeHandler(e)} /> + + + + {/* BEAHVIOR OPTIONS */} diff --git a/client/src/interfaces/Config.ts b/client/src/interfaces/Config.ts index d0152c55..1b60ca7b 100644 --- a/client/src/interfaces/Config.ts +++ b/client/src/interfaces/Config.ts @@ -19,4 +19,5 @@ export interface Config { dockerHost: string; kubernetesApps: boolean; unpinStoppedApps: boolean; + useAmericanDate: boolean; } diff --git a/client/src/interfaces/Forms.ts b/client/src/interfaces/Forms.ts index 9123d628..411ce909 100644 --- a/client/src/interfaces/Forms.ts +++ b/client/src/interfaces/Forms.ts @@ -25,4 +25,5 @@ export interface OtherSettingsForm { dockerHost: string; kubernetesApps: boolean; unpinStoppedApps: boolean; + useAmericanDate: boolean; } diff --git a/client/src/store/actions/config.ts b/client/src/store/actions/config.ts index 8b1ef5a9..79bcebed 100644 --- a/client/src/store/actions/config.ts +++ b/client/src/store/actions/config.ts @@ -20,6 +20,9 @@ export const getConfig = () => async (dispatch: Dispatch) => { // Set custom page title if set document.title = res.data.data.customTitle; + + // Store settings for priority UI elements + localStorage.setItem('useAmericanDate', `${res.data.data.useAmericanDate}`); } catch (err) { console.log(err); } @@ -46,6 +49,9 @@ export const updateConfig = (formData: any) => async (dispatch: Dispatch) => { type: ActionTypes.updateConfig, payload: res.data.data, }); + + // Store settings for priority UI elements + localStorage.setItem('useAmericanDate', `${res.data.data.useAmericanDate}`); } catch (err) { console.log(err); } diff --git a/client/src/utility/templateObjects/configTemplate.ts b/client/src/utility/templateObjects/configTemplate.ts index bbc79981..4d4843fa 100644 --- a/client/src/utility/templateObjects/configTemplate.ts +++ b/client/src/utility/templateObjects/configTemplate.ts @@ -21,4 +21,5 @@ export const configTemplate: Config = { dockerHost: 'localhost', kubernetesApps: false, unpinStoppedApps: false, + useAmericanDate: false, }; diff --git a/client/src/utility/templateObjects/settingsTemplate.ts b/client/src/utility/templateObjects/settingsTemplate.ts index 674931b7..05bc8873 100644 --- a/client/src/utility/templateObjects/settingsTemplate.ts +++ b/client/src/utility/templateObjects/settingsTemplate.ts @@ -14,6 +14,7 @@ export const otherSettingsTemplate: OtherSettingsForm = { dockerHost: 'localhost', kubernetesApps: true, unpinStoppedApps: true, + useAmericanDate: false, }; export const weatherSettingsTemplate: WeatherForm = { diff --git a/controllers/category.js b/controllers/category.js index 557c1a1d..d10183fc 100644 --- a/controllers/category.js +++ b/controllers/category.js @@ -15,14 +15,12 @@ exports.createCategory = asyncWrapper(async (req, res, next) => { let category; if (pinCategories) { - if (parseInt(pinCategories.value)) { - category = await Category.create({ - ...req.body, - isPinned: true, - }); - } else { - category = await Category.create(req.body); - } + category = await Category.create({ + ...req.body, + isPinned: true, + }); + } else { + category = await Category.create(req.body); } res.status(201).json({ From 98924ac00689de2849a305285f226777194113cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Fri, 22 Oct 2021 16:10:38 +0200 Subject: [PATCH 08/88] Pushed version 1.7.1 --- .env | 2 +- CHANGELOG.md | 2 +- client/.env | 2 +- db/migrations/01_new-config.js | 8 ++------ 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.env b/.env index 1bb2edb9..e2c26fca 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ PORT=5005 NODE_ENV=development -VERSION=1.7.0 \ No newline at end of file +VERSION=1.7.1 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f83ab1..fc2dbd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### v1.7.1 (TBA) +### v1.7.1 (2021-10-22) - Fixed search action not being triggered by Numpad Enter - Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92)) - Added shortcuts (Esc and double click) to clear search bar ([#100](https://github.com/pawelmalak/flame/issues/100)) diff --git a/client/.env b/client/.env index 6dbe18b1..15119427 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=1.7.0 \ No newline at end of file +REACT_APP_VERSION=1.7.1 \ No newline at end of file diff --git a/db/migrations/01_new-config.js b/db/migrations/01_new-config.js index 2c42af74..6429e4f4 100644 --- a/db/migrations/01_new-config.js +++ b/db/migrations/01_new-config.js @@ -1,5 +1,3 @@ -const { DataTypes } = require('sequelize'); -const { INTEGER, DATE, STRING, TINYINT, FLOAT, TEXT } = DataTypes; const { readFile, writeFile, copyFile } = require('fs/promises'); const Config = require('../../models/Config'); @@ -28,12 +26,10 @@ const up = async (query) => { const newConfig = JSON.stringify(parsedNewConfig); await writeFile('data/config.json', newConfig); - // await query.dropTable('config'); + await query.dropTable('config'); }; -const down = async (query) => { - // await query.dropTable('config'); -}; +const down = async (query) => {}; module.exports = { up, From df6d96f5b697c8a149a9c5ccba9a5ce70a95918d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Tue, 26 Oct 2021 13:09:42 +0200 Subject: [PATCH 09/88] Added option to disable search bar autofocus --- DEV_GUIDELINES.md | 10 +++++++++ client/src/components/SearchBar/SearchBar.tsx | 21 ++++++++++++++----- .../SearchSettings/SearchSettings.tsx | 12 +++++++++++ client/src/interfaces/Config.ts | 1 + client/src/interfaces/Forms.ts | 1 + .../utility/templateObjects/configTemplate.ts | 1 + .../templateObjects/settingsTemplate.ts | 1 + utils/init/initialConfig.json | 4 +++- 8 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 DEV_GUIDELINES.md diff --git a/DEV_GUIDELINES.md b/DEV_GUIDELINES.md new file mode 100644 index 00000000..462a17fa --- /dev/null +++ b/DEV_GUIDELINES.md @@ -0,0 +1,10 @@ +## Adding new config key + +1. Edit utils/init/initialConfig.json +2. Edit client/src/interfaces/Config.ts +3. Edit client/src/utility/templateObjects/configTemplate.ts + +If config value will be used in a form: + +4. Edit client/src/interfaces/Forms.ts +5. Edit client/src/utility/templateObjects/settingsTemplate.ts \ No newline at end of file diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index b6a981f3..a535c195 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { createNotification } from '../../store/actions'; // Typescript -import { NewNotification } from '../../interfaces'; +import { Config, GlobalState, NewNotification } from '../../interfaces'; // CSS import classes from './SearchBar.module.css'; @@ -16,16 +16,20 @@ import { searchParser, urlParser, redirectUrl } from '../../utility'; interface ComponentProps { createNotification: (notification: NewNotification) => void; setLocalSearch: (query: string) => void; + config: Config; + loading: boolean; } const SearchBar = (props: ComponentProps): JSX.Element => { - const { setLocalSearch, createNotification } = props; + const { setLocalSearch, createNotification, config, loading } = props; const inputRef = useRef(document.createElement('input')); useEffect(() => { - inputRef.current.focus(); - }, []); + if (!loading && !config.disableAutofocus) { + inputRef.current.focus(); + } + }, [config]); const clearSearch = () => { inputRef.current.value = ''; @@ -78,4 +82,11 @@ const SearchBar = (props: ComponentProps): JSX.Element => { ); }; -export default connect(null, { createNotification })(SearchBar); +const mapStateToProps = (state: GlobalState) => { + return { + config: state.config.config, + loading: state.config.loading, + }; +}; + +export default connect(mapStateToProps, { createNotification })(SearchBar); diff --git a/client/src/components/Settings/SearchSettings/SearchSettings.tsx b/client/src/components/Settings/SearchSettings/SearchSettings.tsx index a403fa64..d05def54 100644 --- a/client/src/components/Settings/SearchSettings/SearchSettings.tsx +++ b/client/src/components/Settings/SearchSettings/SearchSettings.tsx @@ -121,6 +121,18 @@ const SearchSettings = (props: Props): JSX.Element => { + + + + diff --git a/client/src/interfaces/Config.ts b/client/src/interfaces/Config.ts index 1b60ca7b..88f1d5c9 100644 --- a/client/src/interfaces/Config.ts +++ b/client/src/interfaces/Config.ts @@ -20,4 +20,5 @@ export interface Config { kubernetesApps: boolean; unpinStoppedApps: boolean; useAmericanDate: boolean; + disableAutofocus: boolean; } diff --git a/client/src/interfaces/Forms.ts b/client/src/interfaces/Forms.ts index 411ce909..6e144bbc 100644 --- a/client/src/interfaces/Forms.ts +++ b/client/src/interfaces/Forms.ts @@ -9,6 +9,7 @@ export interface SearchForm { hideSearch: boolean; defaultSearchProvider: string; searchSameTab: boolean; + disableAutofocus: boolean; } export interface OtherSettingsForm { diff --git a/client/src/utility/templateObjects/configTemplate.ts b/client/src/utility/templateObjects/configTemplate.ts index 4d4843fa..a6f590aa 100644 --- a/client/src/utility/templateObjects/configTemplate.ts +++ b/client/src/utility/templateObjects/configTemplate.ts @@ -22,4 +22,5 @@ export const configTemplate: Config = { kubernetesApps: false, unpinStoppedApps: false, useAmericanDate: false, + disableAutofocus: false, }; diff --git a/client/src/utility/templateObjects/settingsTemplate.ts b/client/src/utility/templateObjects/settingsTemplate.ts index 05bc8873..30fa8714 100644 --- a/client/src/utility/templateObjects/settingsTemplate.ts +++ b/client/src/utility/templateObjects/settingsTemplate.ts @@ -28,4 +28,5 @@ export const searchSettingsTemplate: SearchForm = { hideSearch: false, searchSameTab: false, defaultSearchProvider: 'l', + disableAutofocus: false, }; diff --git a/utils/init/initialConfig.json b/utils/init/initialConfig.json index f6b57a3c..11a839ab 100644 --- a/utils/init/initialConfig.json +++ b/utils/init/initialConfig.json @@ -18,5 +18,7 @@ "dockerApps": false, "dockerHost": "localhost", "kubernetesApps": false, - "unpinStoppedApps": false + "unpinStoppedApps": false, + "useAmericanDate": false, + "disableAutofocus": false } From 3d3e2eed8c679a0a88cac349a58dd31cc3f7bd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Tue, 26 Oct 2021 14:37:01 +0200 Subject: [PATCH 10/88] Fixed bug with weather logging. Fixed bug with search bar shortcuts --- CHANGELOG.md | 5 +++ client/src/components/SearchBar/SearchBar.tsx | 16 +++++++++ utils/getExternalWeather.js | 8 ----- utils/jobs.js | 35 +++++++++++++------ 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2dbd55..0f57ca17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### v1.7.2 (TBA) +- Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124)) +- Fixed bug with Weather API still logging with module being disabled ([#125](https://github.com/pawelmalak/flame/issues/125)) +- Added option to disable search bar autofocus ([#127](https://github.com/pawelmalak/flame/issues/127)) + ### v1.7.1 (2021-10-22) - Fixed search action not being triggered by Numpad Enter - Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92)) diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index a535c195..c20b4577 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -25,12 +25,28 @@ const SearchBar = (props: ComponentProps): JSX.Element => { const inputRef = useRef(document.createElement('input')); + // Search bar autofocus useEffect(() => { if (!loading && !config.disableAutofocus) { inputRef.current.focus(); } }, [config]); + // Listen for keyboard events outside of search bar + useEffect(() => { + const keyOutsideFocus = (e: any) => { + const { key } = e as KeyboardEvent; + + if (key === 'Escape') { + clearSearch(); + } + }; + + window.addEventListener('keydown', keyOutsideFocus); + + return () => window.removeEventListener('keydown', keyOutsideFocus); + }, []); + const clearSearch = () => { inputRef.current.value = ''; setLocalSearch(''); diff --git a/utils/getExternalWeather.js b/utils/getExternalWeather.js index 8b2be8da..20edac42 100644 --- a/utils/getExternalWeather.js +++ b/utils/getExternalWeather.js @@ -5,14 +5,6 @@ const loadConfig = require('./loadConfig'); const getExternalWeather = async () => { const { WEATHER_API_KEY: secret, lat, long } = await loadConfig(); - if (!secret) { - throw new Error('API key was not found. Weather updated failed'); - } - - if (!lat || !long) { - throw new Error('Location was not found. Weather updated failed'); - } - // Fetch data from external API try { const res = await axios.get( diff --git a/utils/jobs.js b/utils/jobs.js index 935f4979..9716af0e 100644 --- a/utils/jobs.js +++ b/utils/jobs.js @@ -3,20 +3,33 @@ const getExternalWeather = require('./getExternalWeather'); const clearWeatherData = require('./clearWeatherData'); const Sockets = require('../Sockets'); const Logger = require('./Logger'); +const loadConfig = require('./loadConfig'); const logger = new Logger(); // Update weather data every 15 minutes -const weatherJob = schedule.scheduleJob('updateWeather', '0 */15 * * * *', async () => { - try { - const weatherData = await getExternalWeather(); - logger.log('Weather updated'); - Sockets.getSocket('weather').socket.send(JSON.stringify(weatherData)); - } catch (err) { - logger.log(err.message, 'ERROR'); +const weatherJob = schedule.scheduleJob( + 'updateWeather', + '0 */15 * * * *', + async () => { + const { WEATHER_API_KEY: secret } = await loadConfig(); + + try { + const weatherData = await getExternalWeather(); + logger.log('Weather updated'); + Sockets.getSocket('weather').socket.send(JSON.stringify(weatherData)); + } catch (err) { + if (secret) { + logger.log(err.message, 'ERROR'); + } + } } -}) +); // Clear old weather data every 4 hours -const weatherCleanerJob = schedule.scheduleJob('clearWeather', '0 5 */4 * * *', async () => { - clearWeatherData(); -}) \ No newline at end of file +const weatherCleanerJob = schedule.scheduleJob( + 'clearWeather', + '0 5 */4 * * *', + async () => { + clearWeatherData(); + } +); From da13ca6092c2e41b77138654f1f87fe089a92842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Wed, 27 Oct 2021 11:52:57 +0200 Subject: [PATCH 11/88] Search bar redirect to local search results --- CHANGELOG.md | 1 + client/src/components/Home/Home.tsx | 57 ++++++++++++------- client/src/components/SearchBar/SearchBar.tsx | 27 +++++++-- client/src/utility/redirectUrl.ts | 8 ++- 4 files changed, 67 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f57ca17..3eae525f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ### v1.7.2 (TBA) +- Pressing Enter while search bar is focused will now redirect to first result of local search ([#121](https://github.com/pawelmalak/flame/issues/121)) - Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124)) - Fixed bug with Weather API still logging with module being disabled ([#125](https://github.com/pawelmalak/flame/issues/125)) - Added option to disable search bar autofocus ([#127](https://github.com/pawelmalak/flame/issues/127)) diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index 18d81bc9..4a0adbeb 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -55,17 +55,21 @@ const Home = (props: ComponentProps): JSX.Element => { // Local search query const [localSearch, setLocalSearch] = useState(null); + const [appSearchResult, setAppSearchResult] = useState(null); + const [bookmarkSearchResult, setBookmarkSearchResult] = useState< + null | Category[] + >(null); // Load applications useEffect(() => { - if (apps.length === 0) { + if (!apps.length) { getApps(); } }, [getApps]); // Load bookmark categories useEffect(() => { - if (categories.length === 0) { + if (!categories.length) { getCategories(); } }, [getCategories]); @@ -87,22 +91,37 @@ const Home = (props: ComponentProps): JSX.Element => { return () => clearInterval(interval); }, []); - // Search bookmarks - const searchBookmarks = (query: string): Category[] => { - const category = { ...categories[0] }; - category.name = 'Search Results'; - category.bookmarks = categories - .map(({ bookmarks }) => bookmarks) - .flat() - .filter(({ name }) => new RegExp(query, 'i').test(name)); - - return [category]; - }; + useEffect(() => { + if (localSearch) { + // Search through apps + setAppSearchResult([ + ...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)), + ]); + + // Search through bookmarks + const category = { ...categories[0] }; + + category.name = 'Search Results'; + category.bookmarks = categories + .map(({ bookmarks }) => bookmarks) + .flat() + .filter(({ name }) => new RegExp(localSearch, 'i').test(name)); + + setBookmarkSearchResult([category]); + } else { + setAppSearchResult(null); + setBookmarkSearchResult(null); + } + }, [localSearch]); return ( {!props.config.hideSearch ? ( - + ) : (
)} @@ -130,11 +149,9 @@ const Home = (props: ComponentProps): JSX.Element => { ) : ( isPinned) - : apps.filter(({ name }) => - new RegExp(localSearch, 'i').test(name) - ) + : appSearchResult } totalApps={apps.length} searching={!!localSearch} @@ -154,9 +171,9 @@ const Home = (props: ComponentProps): JSX.Element => { ) : ( isPinned) - : searchBookmarks(localSearch) + : bookmarkSearchResult } totalCategories={categories.length} searching={!!localSearch} diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index c20b4577..7a91525c 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -5,7 +5,13 @@ import { connect } from 'react-redux'; import { createNotification } from '../../store/actions'; // Typescript -import { Config, GlobalState, NewNotification } from '../../interfaces'; +import { + App, + Category, + Config, + GlobalState, + NewNotification, +} from '../../interfaces'; // CSS import classes from './SearchBar.module.css'; @@ -16,12 +22,21 @@ import { searchParser, urlParser, redirectUrl } from '../../utility'; interface ComponentProps { createNotification: (notification: NewNotification) => void; setLocalSearch: (query: string) => void; + appSearchResult: App[] | null; + bookmarkSearchResult: Category[] | null; config: Config; loading: boolean; } const SearchBar = (props: ComponentProps): JSX.Element => { - const { setLocalSearch, createNotification, config, loading } = props; + const { + setLocalSearch, + createNotification, + config, + loading, + appSearchResult, + bookmarkSearchResult, + } = props; const inputRef = useRef(document.createElement('input')); @@ -73,8 +88,12 @@ const SearchBar = (props: ComponentProps): JSX.Element => { const url = urlParser(inputRef.current.value)[1]; redirectUrl(url, sameTab); } else if (isLocal) { - // Local query -> filter apps and bookmarks - setLocalSearch(search); + // Local query -> redirect if at least 1 result found + if (appSearchResult?.length) { + redirectUrl(appSearchResult[0].url, sameTab); + } else if (bookmarkSearchResult?.length) { + redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab); + } } else { // Valid query -> redirect to search results const url = `${query.template}${search}`; diff --git a/client/src/utility/redirectUrl.ts b/client/src/utility/redirectUrl.ts index 81eca108..533f5d24 100644 --- a/client/src/utility/redirectUrl.ts +++ b/client/src/utility/redirectUrl.ts @@ -1,7 +1,11 @@ +import { urlParser } from '.'; + export const redirectUrl = (url: string, sameTab: boolean) => { + const parsedUrl = urlParser(url)[1]; + if (sameTab) { - document.location.replace(url); + document.location.replace(parsedUrl); } else { - window.open(url); + window.open(parsedUrl); } }; From feb7275cf8648d99b1d2aa1e27ae050cb53285a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Thu, 28 Oct 2021 11:44:36 +0200 Subject: [PATCH 12/88] Pushed version 1.7.2 --- .env | 2 +- CHANGELOG.md | 2 +- client/.env | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env b/.env index e2c26fca..3288f33c 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ PORT=5005 NODE_ENV=development -VERSION=1.7.1 \ No newline at end of file +VERSION=1.7.2 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eae525f..25d796bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### v1.7.2 (TBA) +### v1.7.2 (2021-10-28) - Pressing Enter while search bar is focused will now redirect to first result of local search ([#121](https://github.com/pawelmalak/flame/issues/121)) - Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124)) - Fixed bug with Weather API still logging with module being disabled ([#125](https://github.com/pawelmalak/flame/issues/125)) diff --git a/client/.env b/client/.env index 15119427..e16ddf35 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=1.7.1 \ No newline at end of file +REACT_APP_VERSION=1.7.2 \ No newline at end of file From 88694c7e2767040bcf9551fe65eb3ce80a849368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Thu, 28 Oct 2021 16:05:21 +0200 Subject: [PATCH 13/88] Fixed bug with custom css not updating --- .env | 2 +- CHANGELOG.md | 3 +++ client/.env | 2 +- controllers/config/updateCSS.js | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 3288f33c..5c6e879a 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ PORT=5005 NODE_ENV=development -VERSION=1.7.2 \ No newline at end of file +VERSION=1.7.3 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d796bb..7c870d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### v1.7.3 (2021-10-28) +- Fixed bug with custom CSS not updating + ### v1.7.2 (2021-10-28) - Pressing Enter while search bar is focused will now redirect to first result of local search ([#121](https://github.com/pawelmalak/flame/issues/121)) - Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124)) diff --git a/client/.env b/client/.env index e16ddf35..18bbaa34 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=1.7.2 \ No newline at end of file +REACT_APP_VERSION=1.7.3 \ No newline at end of file diff --git a/controllers/config/updateCSS.js b/controllers/config/updateCSS.js index 4deea766..4ac476c0 100644 --- a/controllers/config/updateCSS.js +++ b/controllers/config/updateCSS.js @@ -1,6 +1,7 @@ const asyncWrapper = require('../../middleware/asyncWrapper'); const File = require('../../utils/File'); const { join } = require('path'); +const fs = require('fs'); // @desc Update custom CSS file // @route PUT /api/config/0/css From 1d70bd132a5f6a496daf407acf144f6bd101e32f Mon Sep 17 00:00:00 2001 From: Ekrem Parlak Date: Mon, 1 Nov 2021 15:13:06 +0100 Subject: [PATCH 14/88] Update Dockerfile for smaller image --- Dockerfile | 8 +++++++- Dockerfile.multiarch | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fed0789b..fc402f47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14-alpine +FROM node:14-alpine as builder RUN apk update && apk add --no-cache nano curl @@ -18,6 +18,12 @@ RUN mkdir -p ./public ./data \ && mv ./client/build/* ./public \ && rm -rf ./client +FROM node:14-alpine + +COPY --from=builder /app /app + +WORKDIR /app + EXPOSE 5005 ENV NODE_ENV=production diff --git a/Dockerfile.multiarch b/Dockerfile.multiarch index 20ff6c25..a03cb4bb 100644 --- a/Dockerfile.multiarch +++ b/Dockerfile.multiarch @@ -20,6 +20,12 @@ RUN mkdir -p ./public ./data \ && rm -rf ./client \ && apk del build-dependencies +FROM node:14-alpine + +COPY --from=builder /app /app + +WORKDIR /app + EXPOSE 5005 ENV NODE_ENV=production From b45eecada219c75c8c975b159aa2210c59244846 Mon Sep 17 00:00:00 2001 From: Ekrem Date: Mon, 1 Nov 2021 19:08:30 +0300 Subject: [PATCH 15/88] Update Dockerfile.multiarch --- Dockerfile.multiarch | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.multiarch b/Dockerfile.multiarch index a03cb4bb..ea1e6ea5 100644 --- a/Dockerfile.multiarch +++ b/Dockerfile.multiarch @@ -1,4 +1,4 @@ -FROM node:14-alpine +FROM node:14-alpine as builder RUN apk update && apk add --no-cache nano curl @@ -30,4 +30,4 @@ EXPOSE 5005 ENV NODE_ENV=production -CMD ["node", "server.js"] \ No newline at end of file +CMD ["node", "server.js"] From 4ed29fe276b4282c67415f9788660df2a3a69b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Thu, 4 Nov 2021 23:39:35 +0100 Subject: [PATCH 16/88] Split remaining controllers into separate files. Added iOS homescreen icon. Removed additional logging from weather module. --- .gitignore | 1 + CHANGELOG.md | 3 + .../public/icons/apple-touch-icon-114x114.png | Bin 0 -> 9581 bytes .../public/icons/apple-touch-icon-120x120.png | Bin 0 -> 7588 bytes .../public/icons/apple-touch-icon-144x144.png | Bin 0 -> 7315 bytes .../public/icons/apple-touch-icon-152x152.png | Bin 0 -> 11565 bytes .../public/icons/apple-touch-icon-180x180.png | Bin 0 -> 20249 bytes .../public/icons/apple-touch-icon-57x57.png | Bin 0 -> 2579 bytes .../public/icons/apple-touch-icon-72x72.png | Bin 0 -> 3311 bytes .../public/icons/apple-touch-icon-76x76.png | Bin 0 -> 4058 bytes client/public/icons/apple-touch-icon.png | Bin 0 -> 2579 bytes client/public/{ => icons}/favicon.ico | Bin client/public/index.html | 46 ++++- controllers/categories/createCategory.js | 28 +++ controllers/categories/deleteCategory.js | 45 +++++ controllers/categories/getAllCategories.js | 43 +++++ controllers/categories/getSingleCategory.js | 35 ++++ controllers/categories/index.js | 8 + controllers/categories/reorderCategories.js | 22 +++ controllers/categories/updateCategory.js | 30 +++ controllers/category.js | 178 ------------------ controllers/queries/addQuery.js | 21 +++ controllers/queries/deleteQuery.js | 22 +++ controllers/queries/getQueries.js | 17 ++ controllers/queries/index.js | 87 +-------- controllers/queries/updateQuery.js | 32 ++++ controllers/weather.js | 31 --- controllers/weather/getWather.js | 19 ++ controllers/weather/index.js | 4 + controllers/weather/updateWeather.js | 16 ++ routes/category.js | 21 +-- utils/clearWeatherData.js | 21 ++- 32 files changed, 418 insertions(+), 312 deletions(-) create mode 100644 client/public/icons/apple-touch-icon-114x114.png create mode 100644 client/public/icons/apple-touch-icon-120x120.png create mode 100644 client/public/icons/apple-touch-icon-144x144.png create mode 100644 client/public/icons/apple-touch-icon-152x152.png create mode 100644 client/public/icons/apple-touch-icon-180x180.png create mode 100644 client/public/icons/apple-touch-icon-57x57.png create mode 100644 client/public/icons/apple-touch-icon-72x72.png create mode 100644 client/public/icons/apple-touch-icon-76x76.png create mode 100644 client/public/icons/apple-touch-icon.png rename client/public/{ => icons}/favicon.ico (100%) create mode 100644 controllers/categories/createCategory.js create mode 100644 controllers/categories/deleteCategory.js create mode 100644 controllers/categories/getAllCategories.js create mode 100644 controllers/categories/getSingleCategory.js create mode 100644 controllers/categories/index.js create mode 100644 controllers/categories/reorderCategories.js create mode 100644 controllers/categories/updateCategory.js delete mode 100644 controllers/category.js create mode 100644 controllers/queries/addQuery.js create mode 100644 controllers/queries/deleteQuery.js create mode 100644 controllers/queries/getQueries.js create mode 100644 controllers/queries/updateQuery.js delete mode 100644 controllers/weather.js create mode 100644 controllers/weather/getWather.js create mode 100644 controllers/weather/index.js create mode 100644 controllers/weather/updateWeather.js diff --git a/.gitignore b/.gitignore index 98ec8629..147804b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules data public +!client/public build.sh \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c870d10..afd72979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### v1.7.4 (TBA) +- Added iOS "Add to homescreen" icon ([#131](https://github.com/pawelmalak/flame/issues/131)) + ### v1.7.3 (2021-10-28) - Fixed bug with custom CSS not updating diff --git a/client/public/icons/apple-touch-icon-114x114.png b/client/public/icons/apple-touch-icon-114x114.png new file mode 100644 index 0000000000000000000000000000000000000000..301cd2527deac021ffaf5dafbc3dc72bbfa28597 GIT binary patch literal 9581 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 Xng6tX727L300000NkvXXu0mjf@N93N literal 0 HcmV?d00001 diff --git a/client/public/icons/apple-touch-icon-120x120.png b/client/public/icons/apple-touch-icon-120x120.png new file mode 100644 index 0000000000000000000000000000000000000000..28ba56d603187f1f698fa1d10bc9e8cb9d2f1bf0 GIT binary patch literal 7588 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 literal 0 HcmV?d00001 diff --git a/client/public/icons/apple-touch-icon-57x57.png b/client/public/icons/apple-touch-icon-57x57.png new file mode 100644 index 0000000000000000000000000000000000000000..b07d1da59d94de7a617ee11963889b7930ff4ced GIT binary patch literal 2579 zcmV+u3hecXP)6HM;ZTpbI;y;cenR$No@1jP2A?unxtu}!B-GN ziA`#4Q^e5Xiq(W#C{5IAL()H*ScD2iMI<0t6oi5gum!Oc0;V;TNCOqCo325k;e zMQ)TSWh*Nw^;=937-gRXEFdMpm(q zOd1(S#TLtAheJ!~8CK>pz<80!67mCv64P96fZ47L$smeInbJ_h+T!U{dwWsYv;{mh zh3SJY>l&iQYu4gd@=O4jSrb;GjjYF#!0VRG$(n5vqtdO8ITZ$*iWCH>Y~6nznF6JWHR<=mEu(|YL?K<>6{UcCcA7t1H zkB@@GuuvGt3ny&Ifi=X_X|S5Zuj`JF?t2#0 z-E9#h<64go0OHz;FhK9#{Sfs&yKk5`|7t7E^`B(r%MT*Bb(QmbX>d6}bj?A(=tz_W z+EcBL>O)`m4}S9kh^2$qM_t*3ZidU{7!0I=A-?C>opE;Vm3y~B)argg5TUZ=zL-W7 z$5?fbGIT2Zur{W0&jv(Pz6q$1(+aw!bBNW7qdQVji24^JkMia(6;tVZw?Zsf7=w*; zjCi0FfX5Tn92Hx}S2y>h*(7Na(czT>&$vji0!o9+Ao`ai8o|uY3)OD)eD5hB2%?o# zA%(&ZJAt+1wW49dzI=8RKvsMiy(-etY2hN&o;!fb1KXVZxN<_SwnEOG6*;iypOp~Oz5P*$S{?1i zgifPW@=hc-#&&g~`?2q$`tTzK^hxK<#;;)M#o`SQ$KI4!{ubar46H8w26VVR0JQ$~7I<>f4VdSDBx*Nu z42>rrM|#Rh7sVl zcuQtRY2|HEj#M$Q@boE6|M?|UwhcLfgBVy^_?#pgLaY}#CB@@o8UyNtNeEC^m-fvB z1)|mm2$7*b^ZH+PTBaat_0Cq(y=rXDH#30n-IJE^$4&;#=k|qhpxw^UZl65^Znr@~ zB&)2^X|+09$$*KnCjhJ;u%y}O=tX$^9q`Ny5U-duqu}|?96l5$%heMb26gvnYHZR= zL5A%>gq+EDA;FLuGGL+!Bb;!viPp$)N4T_o?tM)E@wW+GH8cgQ)_D7dCq@-BBD-lx zUYX4h0x+eaMBB}0oO1MS_Vw51KEe0Tp!xJ}v>S~~XaW(+TRbU~>1nh^h8;n}7f@Kq z46ws;T`G6zAb7G_c=e`!^E@UV-;VIq344}(<0b^_);np;9z6oSJlR>cjyPur>SDYx zf$5k2P%wqbn0@mNz``UEp}cVudY^d8+kfiC-(xOC9NHKc?*1%0peOZuO7tAUk)NXbsQ)K2-d7c<(~Ty+_~O6&g|hsn0$6$VaJx?Gpfk?5Q*C* zA4Tn_zi=J}eAr)md<+vicYr^*)IkQEuFf$*`}8R^_wA0e16O&1%?6s!?CoHJ#x5jH zQ!56^Y)N9;p5t@Ikz;7?-ih$N|K_TcD+Zcgy*)mL=HBlheCOYDOs!?#34dvkZ?d|y z!V{xt?An2uSN;SJ!^~t{lh3?o#_ZuknAo`^o*+50utpeQSH_lOo0L@+2~sc$WDF)! zbdeS$sNYJtqAVu)HFg7u$5uzD>@14|+1%?EmVA{H)Bg3q5v z`0h!xMuyS)$D4^=Yh+nHk1-INDsW4k3~AogfnBX2fh{=El%B61NMhkkzQw^M8+3ie zeHwO6@pfS4(>Qxztqe*z8zDFW6JRe^QVy();}ptP+prUgC;1DC6I+@*jJ|~ie(3RlXzXVVQ)28r563e=OkbmyZ?E3ZJ$gKwGKWt}eTaA~-2IE@Z@ z3kcQXK$eOCH?$?^##-&kR@qt#2y;*FIb#Cs*(__DX39YgZP)hvRbf*@M} z1SaF?#Nvru_`+Nj&e_oTj^onv4NHH9an?cFskB**l@_H*$CCtfhZSgnoHIt58oq7~ zq?HC6wyszs#Wcw%IVGnPBFz~|FrX&JlXwr3M|V2^?0{dn_G5Hrr7DB9Y7RY|sL)xS pxunZY#%WgCjt!`-`3t4?;eWk|mdmBFm`4Br002ovPDHLkV1h8T`P={i literal 0 HcmV?d00001 diff --git a/client/public/icons/apple-touch-icon-72x72.png b/client/public/icons/apple-touch-icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..0ebf2c8ccfdf80922cf7ab5ff18512dd0a5e9a83 GIT binary patch literal 3311 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 literal 0 HcmV?d00001 diff --git a/client/public/icons/apple-touch-icon-76x76.png b/client/public/icons/apple-touch-icon-76x76.png new file mode 100644 index 0000000000000000000000000000000000000000..d636fe97d49405bb55f3a1062d40fdf5b0cf266c GIT binary patch literal 4058 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 literal 0 HcmV?d00001 diff --git a/client/public/favicon.ico b/client/public/icons/favicon.ico similarity index 100% rename from client/public/favicon.ico rename to client/public/icons/favicon.ico diff --git a/client/public/index.html b/client/public/index.html index c93d95eb..32e17fef 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -2,7 +2,51 @@ - + + + + + + + + + + { + const { pinCategoriesByDefault: pinCategories } = await loadConfig(); + + let category; + + if (pinCategories) { + category = await Category.create({ + ...req.body, + isPinned: true, + }); + } else { + category = await Category.create(req.body); + } + + res.status(201).json({ + success: true, + data: category, + }); +}); + +module.exports = createCategory; diff --git a/controllers/categories/deleteCategory.js b/controllers/categories/deleteCategory.js new file mode 100644 index 00000000..e9b004ba --- /dev/null +++ b/controllers/categories/deleteCategory.js @@ -0,0 +1,45 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const ErrorResponse = require('../../utils/ErrorResponse'); +const Category = require('../../models/Category'); +const Bookmark = require('../../models/Bookmark'); + +// @desc Delete category +// @route DELETE /api/categories/:id +// @access Public +const deleteCategory = asyncWrapper(async (req, res, next) => { + const category = await Category.findOne({ + where: { id: req.params.id }, + include: [ + { + model: Bookmark, + as: 'bookmarks', + }, + ], + }); + + if (!category) { + return next( + new ErrorResponse( + `Category with id of ${req.params.id} was not found`, + 404 + ) + ); + } + + category.bookmarks.forEach(async (bookmark) => { + await Bookmark.destroy({ + where: { id: bookmark.id }, + }); + }); + + await Category.destroy({ + where: { id: req.params.id }, + }); + + res.status(200).json({ + success: true, + data: {}, + }); +}); + +module.exports = deleteCategory; diff --git a/controllers/categories/getAllCategories.js b/controllers/categories/getAllCategories.js new file mode 100644 index 00000000..597bfccf --- /dev/null +++ b/controllers/categories/getAllCategories.js @@ -0,0 +1,43 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const Category = require('../../models/Category'); +const Bookmark = require('../../models/Bookmark'); +const { Sequelize } = require('sequelize'); +const loadConfig = require('../../utils/loadConfig'); + +// @desc Get all categories +// @route GET /api/categories +// @access Public +const getAllCategories = asyncWrapper(async (req, res, next) => { + const { useOrdering: orderType } = await loadConfig(); + + let categories; + + if (orderType == 'name') { + categories = await Category.findAll({ + include: [ + { + model: Bookmark, + as: 'bookmarks', + }, + ], + order: [[Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC']], + }); + } else { + categories = await Category.findAll({ + include: [ + { + model: Bookmark, + as: 'bookmarks', + }, + ], + order: [[orderType, 'ASC']], + }); + } + + res.status(200).json({ + success: true, + data: categories, + }); +}); + +module.exports = getAllCategories; diff --git a/controllers/categories/getSingleCategory.js b/controllers/categories/getSingleCategory.js new file mode 100644 index 00000000..084362b9 --- /dev/null +++ b/controllers/categories/getSingleCategory.js @@ -0,0 +1,35 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const ErrorResponse = require('../../utils/ErrorResponse'); +const Category = require('../../models/Category'); +const Bookmark = require('../../models/Bookmark'); + +// @desc Get single category +// @route GET /api/categories/:id +// @access Public +const getSingleCategory = asyncWrapper(async (req, res, next) => { + const category = await Category.findOne({ + where: { id: req.params.id }, + include: [ + { + model: Bookmark, + as: 'bookmarks', + }, + ], + }); + + if (!category) { + return next( + new ErrorResponse( + `Category with id of ${req.params.id} was not found`, + 404 + ) + ); + } + + res.status(200).json({ + success: true, + data: category, + }); +}); + +module.exports = getSingleCategory; diff --git a/controllers/categories/index.js b/controllers/categories/index.js new file mode 100644 index 00000000..8b3c1796 --- /dev/null +++ b/controllers/categories/index.js @@ -0,0 +1,8 @@ +module.exports = { + createCategory: require('./createCategory'), + getAllCategories: require('./getAllCategories'), + getSingleCategory: require('./getSingleCategory'), + updateCategory: require('./updateCategory'), + deleteCategory: require('./deleteCategory'), + reorderCategories: require('./reorderCategories'), +}; diff --git a/controllers/categories/reorderCategories.js b/controllers/categories/reorderCategories.js new file mode 100644 index 00000000..492675b4 --- /dev/null +++ b/controllers/categories/reorderCategories.js @@ -0,0 +1,22 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const Category = require('../../models/Category'); +// @desc Reorder categories +// @route PUT /api/categories/0/reorder +// @access Public +const reorderCategories = asyncWrapper(async (req, res, next) => { + req.body.categories.forEach(async ({ id, orderId }) => { + await Category.update( + { orderId }, + { + where: { id }, + } + ); + }); + + res.status(200).json({ + success: true, + data: {}, + }); +}); + +module.exports = reorderCategories; diff --git a/controllers/categories/updateCategory.js b/controllers/categories/updateCategory.js new file mode 100644 index 00000000..cc43db6d --- /dev/null +++ b/controllers/categories/updateCategory.js @@ -0,0 +1,30 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const ErrorResponse = require('../../utils/ErrorResponse'); +const Category = require('../../models/Category'); + +// @desc Update category +// @route PUT /api/categories/:id +// @access Public +const updateCategory = asyncWrapper(async (req, res, next) => { + let category = await Category.findOne({ + where: { id: req.params.id }, + }); + + if (!category) { + return next( + new ErrorResponse( + `Category with id of ${req.params.id} was not found`, + 404 + ) + ); + } + + category = await category.update({ ...req.body }); + + res.status(200).json({ + success: true, + data: category, + }); +}); + +module.exports = updateCategory; diff --git a/controllers/category.js b/controllers/category.js deleted file mode 100644 index d10183fc..00000000 --- a/controllers/category.js +++ /dev/null @@ -1,178 +0,0 @@ -const asyncWrapper = require('../middleware/asyncWrapper'); -const ErrorResponse = require('../utils/ErrorResponse'); -const Category = require('../models/Category'); -const Bookmark = require('../models/Bookmark'); -const Config = require('../models/Config'); -const { Sequelize } = require('sequelize'); -const loadConfig = require('../utils/loadConfig'); - -// @desc Create new category -// @route POST /api/categories -// @access Public -exports.createCategory = asyncWrapper(async (req, res, next) => { - const { pinCategoriesByDefault: pinCategories } = await loadConfig(); - - let category; - - if (pinCategories) { - category = await Category.create({ - ...req.body, - isPinned: true, - }); - } else { - category = await Category.create(req.body); - } - - res.status(201).json({ - success: true, - data: category, - }); -}); - -// @desc Get all categories -// @route GET /api/categories -// @access Public -exports.getCategories = asyncWrapper(async (req, res, next) => { - const { useOrdering: orderType } = await loadConfig(); - - let categories; - - if (orderType == 'name') { - categories = await Category.findAll({ - include: [ - { - model: Bookmark, - as: 'bookmarks', - }, - ], - order: [[Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC']], - }); - } else { - categories = await Category.findAll({ - include: [ - { - model: Bookmark, - as: 'bookmarks', - }, - ], - order: [[orderType, 'ASC']], - }); - } - - res.status(200).json({ - success: true, - data: categories, - }); -}); - -// @desc Get single category -// @route GET /api/categories/:id -// @access Public -exports.getCategory = asyncWrapper(async (req, res, next) => { - const category = await Category.findOne({ - where: { id: req.params.id }, - include: [ - { - model: Bookmark, - as: 'bookmarks', - }, - ], - }); - - if (!category) { - return next( - new ErrorResponse( - `Category with id of ${req.params.id} was not found`, - 404 - ) - ); - } - - res.status(200).json({ - success: true, - data: category, - }); -}); - -// @desc Update category -// @route PUT /api/categories/:id -// @access Public -exports.updateCategory = asyncWrapper(async (req, res, next) => { - let category = await Category.findOne({ - where: { id: req.params.id }, - }); - - if (!category) { - return next( - new ErrorResponse( - `Category with id of ${req.params.id} was not found`, - 404 - ) - ); - } - - category = await category.update({ ...req.body }); - - res.status(200).json({ - success: true, - data: category, - }); -}); - -// @desc Delete category -// @route DELETE /api/categories/:id -// @access Public -exports.deleteCategory = asyncWrapper(async (req, res, next) => { - const category = await Category.findOne({ - where: { id: req.params.id }, - include: [ - { - model: Bookmark, - as: 'bookmarks', - }, - ], - }); - - if (!category) { - return next( - new ErrorResponse( - `Category with id of ${req.params.id} was not found`, - 404 - ) - ); - } - - category.bookmarks.forEach(async (bookmark) => { - await Bookmark.destroy({ - where: { id: bookmark.id }, - }); - }); - - await Category.destroy({ - where: { id: req.params.id }, - }); - - res.status(200).json({ - success: true, - data: {}, - }); -}); - -// @desc Reorder categories -// @route PUT /api/categories/0/reorder -// @access Public -exports.reorderCategories = asyncWrapper(async (req, res, next) => { - req.body.categories.forEach(async ({ id, orderId }) => { - await Category.update( - { orderId }, - { - where: { id }, - } - ); - }); - - res.status(200).json({ - success: true, - data: {}, - }); -}); diff --git a/controllers/queries/addQuery.js b/controllers/queries/addQuery.js new file mode 100644 index 00000000..cd61c67e --- /dev/null +++ b/controllers/queries/addQuery.js @@ -0,0 +1,21 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); + +// @desc Add custom search query +// @route POST /api/queries +// @access Public +const addQuery = asyncWrapper(async (req, res, next) => { + const file = new File('data/customQueries.json'); + let content = JSON.parse(file.read()); + + // Add new query + content.queries.push(req.body); + file.write(content, true); + + res.status(201).json({ + success: true, + data: req.body, + }); +}); + +module.exports = addQuery; diff --git a/controllers/queries/deleteQuery.js b/controllers/queries/deleteQuery.js new file mode 100644 index 00000000..1a30041b --- /dev/null +++ b/controllers/queries/deleteQuery.js @@ -0,0 +1,22 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); + +// @desc Delete query +// @route DELETE /api/queries/:prefix +// @access Public +const deleteQuery = asyncWrapper(async (req, res, next) => { + const file = new File('data/customQueries.json'); + let content = JSON.parse(file.read()); + + content.queries = content.queries.filter( + (q) => q.prefix != req.params.prefix + ); + file.write(content, true); + + res.status(200).json({ + success: true, + data: content.queries, + }); +}); + +module.exports = deleteQuery; diff --git a/controllers/queries/getQueries.js b/controllers/queries/getQueries.js new file mode 100644 index 00000000..6299473f --- /dev/null +++ b/controllers/queries/getQueries.js @@ -0,0 +1,17 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); + +// @desc Get custom queries file +// @route GET /api/queries +// @access Public +const getQueries = asyncWrapper(async (req, res, next) => { + const file = new File('data/customQueries.json'); + const content = JSON.parse(file.read()); + + res.status(200).json({ + success: true, + data: content.queries, + }); +}); + +module.exports = getQueries; diff --git a/controllers/queries/index.js b/controllers/queries/index.js index ae1ccec0..3d5d0367 100644 --- a/controllers/queries/index.js +++ b/controllers/queries/index.js @@ -1,81 +1,6 @@ -const asyncWrapper = require('../../middleware/asyncWrapper'); -const File = require('../../utils/File'); -const { join } = require('path'); - -const QUERIES_PATH = join(__dirname, '../../data/customQueries.json'); - -// @desc Add custom search query -// @route POST /api/queries -// @access Public -exports.addQuery = asyncWrapper(async (req, res, next) => { - const file = new File(QUERIES_PATH); - let content = JSON.parse(file.read()); - - // Add new query - content.queries.push(req.body); - file.write(content, true); - - res.status(201).json({ - success: true, - data: req.body, - }); -}); - -// @desc Get custom queries file -// @route GET /api/queries -// @access Public -exports.getQueries = asyncWrapper(async (req, res, next) => { - const file = new File(QUERIES_PATH); - const content = JSON.parse(file.read()); - - res.status(200).json({ - success: true, - data: content.queries, - }); -}); - -// @desc Update query -// @route PUT /api/queries/:prefix -// @access Public -exports.updateQuery = asyncWrapper(async (req, res, next) => { - const file = new File(QUERIES_PATH); - let content = JSON.parse(file.read()); - - let queryIdx = content.queries.findIndex( - (q) => q.prefix == req.params.prefix - ); - - // query found - if (queryIdx > -1) { - content.queries = [ - ...content.queries.slice(0, queryIdx), - req.body, - ...content.queries.slice(queryIdx + 1), - ]; - } - - file.write(content, true); - - res.status(200).json({ - success: true, - data: content.queries, - }); -}); - -// @desc Delete query -// @route DELETE /api/queries/:prefix -// @access Public -exports.deleteQuery = asyncWrapper(async (req, res, next) => { - const file = new File(QUERIES_PATH); - let content = JSON.parse(file.read()); - - content.queries = content.queries.filter( - (q) => q.prefix != req.params.prefix - ); - file.write(content, true); - - res.status(200).json({ - success: true, - data: content.queries, - }); -}); +module.exports = { + addQuery: require('./addQuery'), + getQueries: require('./getQueries'), + updateQuery: require('./updateQuery'), + deleteQuery: require('./deleteQuery'), +}; diff --git a/controllers/queries/updateQuery.js b/controllers/queries/updateQuery.js new file mode 100644 index 00000000..a95b71aa --- /dev/null +++ b/controllers/queries/updateQuery.js @@ -0,0 +1,32 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); + +// @desc Update query +// @route PUT /api/queries/:prefix +// @access Public +const updateQuery = asyncWrapper(async (req, res, next) => { + const file = new File('data/customQueries.json'); + let content = JSON.parse(file.read()); + + let queryIdx = content.queries.findIndex( + (q) => q.prefix == req.params.prefix + ); + + // query found + if (queryIdx > -1) { + content.queries = [ + ...content.queries.slice(0, queryIdx), + req.body, + ...content.queries.slice(queryIdx + 1), + ]; + } + + file.write(content, true); + + res.status(200).json({ + success: true, + data: content.queries, + }); +}); + +module.exports = updateQuery; diff --git a/controllers/weather.js b/controllers/weather.js deleted file mode 100644 index 3acd1ad9..00000000 --- a/controllers/weather.js +++ /dev/null @@ -1,31 +0,0 @@ -const asyncWrapper = require('../middleware/asyncWrapper'); -const ErrorResponse = require('../utils/ErrorResponse'); -const Weather = require('../models/Weather'); -const getExternalWeather = require('../utils/getExternalWeather'); - -// @desc Get latest weather status -// @route GET /api/weather -// @access Public -exports.getWeather = asyncWrapper(async (req, res, next) => { - const weather = await Weather.findAll({ - order: [['createdAt', 'DESC']], - limit: 1, - }); - - res.status(200).json({ - success: true, - data: weather, - }); -}); - -// @desc Update weather -// @route GET /api/weather/update -// @access Public -exports.updateWeather = asyncWrapper(async (req, res, next) => { - const weather = await getExternalWeather(); - - res.status(200).json({ - success: true, - data: weather, - }); -}); diff --git a/controllers/weather/getWather.js b/controllers/weather/getWather.js new file mode 100644 index 00000000..44e6e3f3 --- /dev/null +++ b/controllers/weather/getWather.js @@ -0,0 +1,19 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const Weather = require('../../models/Weather'); + +// @desc Get latest weather status +// @route GET /api/weather +// @access Public +const getWeather = asyncWrapper(async (req, res, next) => { + const weather = await Weather.findAll({ + order: [['createdAt', 'DESC']], + limit: 1, + }); + + res.status(200).json({ + success: true, + data: weather, + }); +}); + +module.exports = getWeather; diff --git a/controllers/weather/index.js b/controllers/weather/index.js new file mode 100644 index 00000000..8c7231da --- /dev/null +++ b/controllers/weather/index.js @@ -0,0 +1,4 @@ +module.exports = { + getWeather: require('./getWather'), + updateWeather: require('./updateWeather'), +}; diff --git a/controllers/weather/updateWeather.js b/controllers/weather/updateWeather.js new file mode 100644 index 00000000..c66417e3 --- /dev/null +++ b/controllers/weather/updateWeather.js @@ -0,0 +1,16 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const getExternalWeather = require('../../utils/getExternalWeather'); + +// @desc Update weather +// @route GET /api/weather/update +// @access Public +const updateWeather = asyncWrapper(async (req, res, next) => { + const weather = await getExternalWeather(); + + res.status(200).json({ + success: true, + data: weather, + }); +}); + +module.exports = updateWeather; diff --git a/routes/category.js b/routes/category.js index 64067d7b..b7527c83 100644 --- a/routes/category.js +++ b/routes/category.js @@ -3,26 +3,21 @@ const router = express.Router(); const { createCategory, - getCategories, - getCategory, + getAllCategories, + getSingleCategory, updateCategory, deleteCategory, - reorderCategories -} = require('../controllers/category'); + reorderCategories, +} = require('../controllers/categories'); -router - .route('/') - .post(createCategory) - .get(getCategories); +router.route('/').post(createCategory).get(getAllCategories); router .route('/:id') - .get(getCategory) + .get(getSingleCategory) .put(updateCategory) .delete(deleteCategory); -router - .route('/0/reorder') - .put(reorderCategories); +router.route('/0/reorder').put(reorderCategories); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/utils/clearWeatherData.js b/utils/clearWeatherData.js index 07be15bb..5e4972a9 100644 --- a/utils/clearWeatherData.js +++ b/utils/clearWeatherData.js @@ -2,23 +2,28 @@ const { Op } = require('sequelize'); const Weather = require('../models/Weather'); const Logger = require('./Logger'); const logger = new Logger(); +const loadConfig = require('./loadConfig'); const clearWeatherData = async () => { + const { WEATHER_API_KEY: secret } = await loadConfig(); + const weather = await Weather.findOne({ - order: [[ 'createdAt', 'DESC' ]] + order: [['createdAt', 'DESC']], }); if (weather) { await Weather.destroy({ where: { id: { - [Op.lt]: weather.id - } - } - }) + [Op.lt]: weather.id, + }, + }, + }); } - logger.log('Old weather data was deleted'); -} + if (secret) { + logger.log('Old weather data was deleted'); + } +}; -module.exports = clearWeatherData; \ No newline at end of file +module.exports = clearWeatherData; From 4e205278341967dc7823d2e15c33c92cddae8408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Fri, 5 Nov 2021 15:05:33 +0100 Subject: [PATCH 17/88] Added new themes --- client/src/components/Themer/themes.json | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/client/src/components/Themer/themes.json b/client/src/components/Themer/themes.json index 812191f0..f3b12bdc 100644 --- a/client/src/components/Themer/themes.json +++ b/client/src/components/Themer/themes.json @@ -95,6 +95,30 @@ "primary": "#4C432E", "accent": "#AA9A73" } + }, + { + "name": "neon", + "colors": { + "background": "#091833", + "primary": "#EFFBFF", + "accent": "#ea00d9" + } + }, + { + "name": "pumpkin", + "colors": { + "background": "#2d3436", + "primary": "#EFFBFF", + "accent": "#ffa500" + } + }, + { + "name": "onedark", + "colors": { + "background": "#282c34", + "primary": "#dfd9d6", + "accent": "#98c379" + } } ] -} \ No newline at end of file +} From aca8b0261e28d3be56185220415ce3fe0ca788dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Malak?= Date: Fri, 5 Nov 2021 16:39:42 +0100 Subject: [PATCH 18/88] Added option to set custom greetings. Moved HomeHeader to separate file. Cleaned up README file --- CHANGELOG.md | 2 + README.md | 57 +++++++------------ .../components/Home/Header/Header.module.css | 31 ++++++++++ client/src/components/Home/Header/Header.tsx | 49 ++++++++++++++++ .../functions/getDateTime.ts} | 2 +- .../Home/Header/functions/greeter.ts | 17 ++++++ client/src/components/Home/Home.module.css | 32 +---------- client/src/components/Home/Home.tsx | 43 +------------- .../src/components/Home/functions/greeter.ts | 12 ---- .../Settings/OtherSettings/OtherSettings.tsx | 15 +++++ client/src/interfaces/Config.ts | 1 + client/src/interfaces/Forms.ts | 1 + client/src/store/actions/config.ts | 2 + .../utility/templateObjects/configTemplate.ts | 1 + .../templateObjects/settingsTemplate.ts | 1 + utils/ErrorResponse.js | 4 +- utils/init/initialConfig.json | 3 +- 17 files changed, 149 insertions(+), 124 deletions(-) create mode 100644 client/src/components/Home/Header/Header.module.css create mode 100644 client/src/components/Home/Header/Header.tsx rename client/src/components/Home/{functions/dateTime.ts => Header/functions/getDateTime.ts} (94%) create mode 100644 client/src/components/Home/Header/functions/greeter.ts delete mode 100644 client/src/components/Home/functions/greeter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index afd72979..1011cad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ### v1.7.4 (TBA) +- [WIP] Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103)) - Added iOS "Add to homescreen" icon ([#131](https://github.com/pawelmalak/flame/issues/131)) +- Added 3 new themes ### v1.7.3 (2021-10-28) - Fixed bug with custom CSS not updating diff --git a/README.md b/README.md index e3fd2d71..0fcf5090 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,10 @@ # Flame -[![JS Badge](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black)](https://shields.io/) -[![TS Badge](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://shields.io/) -[![Node Badge](https://img.shields.io/badge/Node.js-43853D?style=for-the-badge&logo=node.js&logoColor=white)](https://shields.io/) -[![React Badge](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)](https://shields.io/) - ![Homescreen screenshot](./.github/_home.png) ## Description -Flame is self-hosted startpage for your server. 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. +Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary. ## Technology @@ -42,7 +37,15 @@ npm run dev ### With Docker (recommended) -[Docker Hub](https://hub.docker.com/r/pawelmalak/flame) +[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame) + +```sh +docker pull pawelmalak/flame:latest + +# for ARM architecture (e.g. RaspberryPi) +docker pull pawelmalak/flame:multiarch +``` + #### Building images @@ -96,13 +99,13 @@ Follow instructions from wiki: [Installation without Docker](https://github.com/ - Applications - Create, update, delete and organize applications using GUI - - Pin your favourite apps to homescreen + - Pin your favourite apps to the homescreen ![Homescreen screenshot](./.github/_apps.png) - Bookmarks - Create, update, delete and organize bookmarks and categories using GUI - - Pin your favourite categories to homescreen + - Pin your favourite categories to the homescreen ![Homescreen screenshot](./.github/_bookmarks.png) @@ -111,7 +114,7 @@ Follow instructions from wiki: [Installation without Docker](https://github.com/ - Get current temperature, cloud coverage and weather status with animated icons - Themes - - Customize your page by choosing from 12 color themes + - Customize your page by choosing from 15 color themes ![Homescreen screenshot](./.github/_themes.png) @@ -125,23 +128,7 @@ To use search bar you need to type your search query with selected prefix. For e > You can change where to open search results (same/new tab) in the settings -#### 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= | -| Spotify | /sp | https://open.spotify.com/search/ | -| The Movie Database | /mv | https://www.themoviedb.org/search?query= | -| Youtube | /yt | https://www.youtube.com/results?search_query= | +For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar). ### Setting up weather module @@ -159,13 +146,13 @@ labels: - flame.type=application # "app" works too - flame.name=My container - flame.url=https://example.com - - flame.icon=icon-name # Optional, default is "docker" + - flame.icon=icon-name # optional, default is "docker" # - flame.icon=custom to make changes in app. ie: custom icon upload ``` -And you must have activated the Docker sync option in the settings panel. +> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section -You can set up different apps in the same label adding `;` between each one. +You can also set up different apps in the same label adding `;` between each one. ```yml labels: @@ -208,13 +195,11 @@ metadata: - flame.pawelmalak/type=application # "app" works too - flame.pawelmalak/name=My container - flame.pawelmalak/url=https://example.com - - flame.pawelmalak/icon=icon-name # Optional, default is "kubernetes" + - flame.pawelmalak/icon=icon-name # optional, default is "kubernetes" ``` -And you must have activated the Kubernetes sync option in the settings panel. +> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section -### Custom CSS +### Custom CSS and themes -> 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) \ No newline at end of file +See project wiki for [Custom CSS](https://github.com/pawelmalak/flame/wiki/Custom-CSS) and [Custom theme with CSS](https://github.com/pawelmalak/flame/wiki/Custom-theme-with-CSS). \ No newline at end of file diff --git a/client/src/components/Home/Header/Header.module.css b/client/src/components/Home/Header/Header.module.css new file mode 100644 index 00000000..d7ee22b5 --- /dev/null +++ b/client/src/components/Home/Header/Header.module.css @@ -0,0 +1,31 @@ +.Header h1 { + color: var(--color-primary); + font-weight: 700; + font-size: 4em; + display: inline-block; +} + +.Header p { + color: var(--color-primary); + font-weight: 300; + text-transform: uppercase; + height: 30px; +} + +.HeaderMain { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2.5rem; +} + +.SettingsLink { + visibility: visible; + color: var(--color-accent); +} + +@media (min-width: 769px) { + .SettingsLink { + visibility: hidden; + } +} diff --git a/client/src/components/Home/Header/Header.tsx b/client/src/components/Home/Header/Header.tsx new file mode 100644 index 00000000..3b2841b4 --- /dev/null +++ b/client/src/components/Home/Header/Header.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { Config, GlobalState } from '../../../interfaces'; +import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget'; +import { getDateTime } from './functions/getDateTime'; +import { greeter } from './functions/greeter'; +import classes from './Header.module.css'; + +interface Props { + config: Config; +} + +const Header = (props: Props): JSX.Element => { + const [dateTime, setDateTime] = useState(getDateTime()); + const [greeting, setGreeting] = useState(greeter()); + + useEffect(() => { + let dateTimeInterval: NodeJS.Timeout; + + dateTimeInterval = setInterval(() => { + setDateTime(getDateTime()); + setGreeting(greeter()); + }, 1000); + + return () => window.clearInterval(dateTimeInterval); + }, []); + + return ( +

+

{dateTime}

+ + Go to Settings + + +

{greeting}

+ +
+
+ ); +}; + +const mapStateToProps = (state: GlobalState) => { + return { + config: state.config.config, + }; +}; + +export default connect(mapStateToProps)(Header); diff --git a/client/src/components/Home/functions/dateTime.ts b/client/src/components/Home/Header/functions/getDateTime.ts similarity index 94% rename from client/src/components/Home/functions/dateTime.ts rename to client/src/components/Home/Header/functions/getDateTime.ts index ddcfc705..9f1d6011 100644 --- a/client/src/components/Home/functions/dateTime.ts +++ b/client/src/components/Home/Header/functions/getDateTime.ts @@ -1,4 +1,4 @@ -export const dateTime = (): string => { +export const getDateTime = (): string => { const days = [ 'Sunday', 'Monday', diff --git a/client/src/components/Home/Header/functions/greeter.ts b/client/src/components/Home/Header/functions/greeter.ts new file mode 100644 index 00000000..93b32b4e --- /dev/null +++ b/client/src/components/Home/Header/functions/greeter.ts @@ -0,0 +1,17 @@ +export const greeter = (): string => { + const now = new Date().getHours(); + let msg: string; + + const greetingsSchemaRaw = + localStorage.getItem('greetingsSchema') || + 'Good evening!;Good afternoon!;Good morning!;Good night!'; + const greetingsSchema = greetingsSchemaRaw.split(';'); + + if (now >= 18) msg = greetingsSchema[0]; + else if (now >= 12) msg = greetingsSchema[1]; + else if (now >= 6) msg = greetingsSchema[2]; + else if (now >= 0) msg = greetingsSchema[3]; + else msg = 'Hello!'; + + return msg; +}; diff --git a/client/src/components/Home/Home.module.css b/client/src/components/Home/Home.module.css index 652ca22a..f4251845 100644 --- a/client/src/components/Home/Home.module.css +++ b/client/src/components/Home/Home.module.css @@ -1,24 +1,3 @@ -.Header h1 { - color: var(--color-primary); - font-weight: 700; - font-size: 4em; - display: inline-block; -} - -.Header p { - color: var(--color-primary); - font-weight: 300; - text-transform: uppercase; - height: 30px; -} - -.HeaderMain { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2.5rem; -} - .SettingsButton { width: 35px; height: 35px; @@ -40,21 +19,12 @@ opacity: 1; } -.SettingsLink { - visibility: visible; - color: var(--color-accent); -} - @media (min-width: 769px) { .SettingsButton { visibility: visible; } - - .SettingsLink { - visibility: hidden; - } } .HomeSpace { height: 20px; -} \ No newline at end of file +} diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index 4a0adbeb..017df9c3 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -21,12 +21,8 @@ import classes from './Home.module.css'; // Components import AppGrid from '../Apps/AppGrid/AppGrid'; import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid'; -import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget'; import SearchBar from '../SearchBar/SearchBar'; - -// Functions -import { greeter } from './functions/greeter'; -import { dateTime } from './functions/dateTime'; +import Header from './Header/Header'; interface ComponentProps { getApps: Function; @@ -48,11 +44,6 @@ const Home = (props: ComponentProps): JSX.Element => { categoriesLoading, } = props; - const [header, setHeader] = useState({ - dateTime: dateTime(), - greeting: greeter(), - }); - // Local search query const [localSearch, setLocalSearch] = useState(null); const [appSearchResult, setAppSearchResult] = useState(null); @@ -74,23 +65,6 @@ const Home = (props: ComponentProps): JSX.Element => { } }, [getCategories]); - // Refresh greeter and time - useEffect(() => { - let interval: any; - - // Start interval only when hideHeader is false - if (!props.config.hideHeader) { - interval = setInterval(() => { - setHeader({ - dateTime: dateTime(), - greeting: greeter(), - }); - }, 1000); - } - - return () => clearInterval(interval); - }, []); - useEffect(() => { if (localSearch) { // Search through apps @@ -126,20 +100,7 @@ const Home = (props: ComponentProps): JSX.Element => {
)} - {!props.config.hideHeader ? ( -
-

{header.dateTime}

- - Go to Settings - - -

{header.greeting}

- -
-
- ) : ( -
- )} + {!props.config.hideHeader ?
:
} {!props.config.hideApps ? ( diff --git a/client/src/components/Home/functions/greeter.ts b/client/src/components/Home/functions/greeter.ts deleted file mode 100644 index 64cb2ea9..00000000 --- a/client/src/components/Home/functions/greeter.ts +++ /dev/null @@ -1,12 +0,0 @@ -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; -} \ No newline at end of file diff --git a/client/src/components/Settings/OtherSettings/OtherSettings.tsx b/client/src/components/Settings/OtherSettings/OtherSettings.tsx index 6610b654..b0767353 100644 --- a/client/src/components/Settings/OtherSettings/OtherSettings.tsx +++ b/client/src/components/Settings/OtherSettings/OtherSettings.tsx @@ -187,6 +187,21 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { + + + inputChangeHandler(e)} + /> + + Greetings must be separated with semicolon. Only 4 messages can be + used + + { onChange={(e) => inputChangeHandler(e)} /> + + {/* DATE FORMAT */} + + {/* PIN CATEGORIES */} + + {/* SORT TYPE */} + + {/* APPS OPPENING */} + + {/* BOOKMARKS OPPENING */} { + + {/* CUSTOM GREETINGS */} { used + + {/* CUSTOM DAYS */} + + + inputChangeHandler(e)} + /> + Names must be separated with semicolon + + + {/* CUSTOM MONTHS */} + + + inputChangeHandler(e)} + /> + Names must be separated with semicolon + + + {/* HIDE APPS */} + + {/* HIDE CATEGORIES */} { onChange={(e) => inputChangeHandler(e)} /> + + {/* USE DOCKER API */} + + {/* UNPIN DOCKER APPS */}