diff --git a/.dev/build_dev.sh b/.dev/build_dev.sh new file mode 100644 index 0000000..6c0bd26 --- /dev/null +++ b/.dev/build_dev.sh @@ -0,0 +1 @@ +docker build -t flame:dev -f .docker/Dockerfile . \ No newline at end of file diff --git a/.dev/build_latest.sh b/.dev/build_latest.sh new file mode 100644 index 0000000..47916fb --- /dev/null +++ b/.dev/build_latest.sh @@ -0,0 +1,2 @@ +docker build -t pawelmalak/flame -t "pawelmalak/flame:$1" -f .docker/Dockerfile . \ + && docker push pawelmalak/flame && docker push "pawelmalak/flame:$1" \ No newline at end of file diff --git a/.dev/build_multiarch.sh b/.dev/build_multiarch.sh new file mode 100644 index 0000000..6ebe89c --- /dev/null +++ b/.dev/build_multiarch.sh @@ -0,0 +1,6 @@ +docker buildx build \ + --platform linux/arm/v7,linux/arm64,linux/amd64 \ + -f .docker/Dockerfile.multiarch \ + -t pawelmalak/flame:multiarch \ + -t "pawelmalak/flame:multiarch$1" \ + --push . \ No newline at end of file diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 415e8b1..1a7e4f6 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -32,4 +32,4 @@ EXPOSE 5005 ENV NODE_ENV=production ENV PASSWORD=flame_password -CMD ["node", "server.js"] +CMD ["sh", "-c", "chown -R node /app/data && node server.js"] \ No newline at end of file diff --git a/.docker/Dockerfile.multiarch b/.docker/Dockerfile.multiarch index 42f5082..1c4fb20 100644 --- a/.docker/Dockerfile.multiarch +++ b/.docker/Dockerfile.multiarch @@ -28,4 +28,4 @@ EXPOSE 5005 ENV NODE_ENV=production ENV PASSWORD=flame_password -CMD ["node", "server.js"] \ No newline at end of file +CMD ["sh", "-c", "chown -R node /app/data && node server.js"] \ No newline at end of file diff --git a/.env b/.env index d5f54d5..99ccaf9 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ PORT=5005 NODE_ENV=development -VERSION=2.2.1 +VERSION=2.3.0 PASSWORD=flame_password SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b \ No newline at end of file diff --git a/.gitignore b/.gitignore index 147804b..d53b3ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules data public -!client/public -build.sh \ No newline at end of file +!client/public \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9502adf..86e876f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +### v2.3.0 (2022-03-25) +- Added custom theme editor ([#246](https://github.com/pawelmalak/flame/issues/246)) +- Added option to set secondary search provider ([#295](https://github.com/pawelmalak/flame/issues/295)) +- Fixed bug where pressing Enter with empty search bar would redirect to search results ([#325](https://github.com/pawelmalak/flame/issues/325)) +- Fixed bug where user could create empty app or bookmark which was causing page to go blank ([#332](https://github.com/pawelmalak/flame/issues/332)) +- Added new theme: Mint + +### v2.2.2 (2022-03-21) +- Added option to get user location directly from the app ([#287](https://github.com/pawelmalak/flame/issues/287)) +- Fixed bug with local search not working when using prefix ([#289](https://github.com/pawelmalak/flame/issues/289)) +- Fixed bug with app description not updating when using custom icon ([#310](https://github.com/pawelmalak/flame/issues/310)) +- Changed permissions to some files and directories created by Flame +- Changed some of the settings tabs + ### v2.2.1 (2022-01-08) - Local search will now include app descriptions ([#266](https://github.com/pawelmalak/flame/issues/266)) - Fixed bug with unsupported characters in local search [#279](https://github.com/pawelmalak/flame/issues/279)) diff --git a/README.md b/README.md index 7fb0605..b805f77 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Flame is self-hosted startpage for your server. Its design is inspired (heavily) - 📌 Pin your favourite items to the homescreen for quick and easy access - 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own - 🔑 Authentication system to protect your settings, apps and bookmarks -- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes +- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS, 15 built-in color themes and custom theme builder - ☀️ Weather widget with current temperature, cloud coverage and animated weather status - 🐳 Docker integration to automatically pick and add apps based on their labels diff --git a/api.js b/api.js index 840529a..45be359 100644 --- a/api.js +++ b/api.js @@ -22,6 +22,7 @@ api.use('/api/categories', require('./routes/category')); api.use('/api/bookmarks', require('./routes/bookmark')); api.use('/api/queries', require('./routes/queries')); api.use('/api/auth', require('./routes/auth')); +api.use('/api/themes', require('./routes/themes')); // Custom error handler api.use(errorHandler); diff --git a/client/.env b/client/.env index edd69d9..e8597c1 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=2.2.1 \ No newline at end of file +REACT_APP_VERSION=2.3.0 \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 1e2520e..cff8b80 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,7 +14,7 @@ import NotFound from './NotFound'; import { actionCreators, store } from './store'; import { autoLogin, getConfig } from './store/action-creators'; import { State } from './store/reducers'; -import { checkVersion, decodeToken } from './utility'; +import { checkVersion, decodeToken, parsePABToTheme } from './utility'; // Redux // Utils @@ -31,7 +31,7 @@ export const App = (): JSX.Element => { const { config, loading } = useSelector((state: State) => state.config); const dispath = useDispatch(); - const { fetchQueries, setTheme, logout, createNotification } = + const { fetchQueries, setTheme, logout, createNotification, fetchThemes } = bindActionCreators(actionCreators, dispath); useEffect(() => { @@ -51,9 +51,12 @@ export const App = (): JSX.Element => { } }, 1000); + // load themes + fetchThemes(); + // set user theme if present if (localStorage.theme) { - setTheme(localStorage.theme); + setTheme(parsePABToTheme(localStorage.theme)); } // check for updated @@ -68,7 +71,7 @@ export const App = (): JSX.Element => { // If there is no user theme, set the default one useEffect(() => { if (!loading && !localStorage.theme) { - setTheme(config.defaultTheme, false); + setTheme(parsePABToTheme(config.defaultTheme), false); } }, [loading]); diff --git a/client/src/components/Apps/AppTable/AppTable.tsx b/client/src/components/Apps/AppTable/AppTable.tsx index a3f37e5..9fdf1fd 100644 --- a/client/src/components/Apps/AppTable/AppTable.tsx +++ b/client/src/components/Apps/AppTable/AppTable.tsx @@ -41,7 +41,7 @@ export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => { }, [categoryInEdit]); // Drag and drop handler - const dragEndHanlder = (result: DropResult): void => { + const dragEndHandler = (result: DropResult): void => { if (config.useOrdering !== 'orderId') { createNotification({ title: 'Error', @@ -109,7 +109,7 @@ export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => { )} {categoryInEdit && ( - + {(provided) => ( state.apps); const dispatch = useDispatch(); - const { addApp, updateApp, createNotification } = + const { addApp, updateApp, setEditApp, createNotification } = bindActionCreators(actionCreators, dispatch); const [useCustomIcon, toggleUseCustomIcon] = useState(false); @@ -65,12 +65,25 @@ export const AppsForm = ({ const formSubmitHandler = (e: FormEvent): void => { e.preventDefault(); + for (let field of ['name', 'url', 'icon'] as const) { + if (/^ +$/.test(formData[field])) { + createNotification({ + title: 'Error', + message: `Field cannot be empty: ${field}`, + }); + + return; + } + } + const createFormData = (): FormData => { const data = new FormData(); if (customIcon) { data.append('icon', customIcon); } + data.append('name', formData.name); + data.append('description', formData.description); data.append('url', formData.url); data.append('categoryId', `${formData.categoryId}`); data.append('isPublic', `${formData.isPublic ? 1 : 0}`); diff --git a/client/src/components/Apps/Table/AppsTable.tsx b/client/src/components/Apps/Table/AppsTable.tsx index a3f37e5..9fdf1fd 100644 --- a/client/src/components/Apps/Table/AppsTable.tsx +++ b/client/src/components/Apps/Table/AppsTable.tsx @@ -41,7 +41,7 @@ export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => { }, [categoryInEdit]); // Drag and drop handler - const dragEndHanlder = (result: DropResult): void => { + const dragEndHandler = (result: DropResult): void => { if (config.useOrdering !== 'orderId') { createNotification({ title: 'Error', @@ -109,7 +109,7 @@ export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => { )} {categoryInEdit && ( - + {(provided) => (
{ }, [categories]); // Drag and drop handler - const dragEndHanlder = (result: DropResult): void => { + const dragEndHandler = (result: DropResult): void => { if (config.useOrdering !== 'orderId') { createNotification({ title: 'Error', @@ -95,12 +95,12 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => { ) : (

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

)} - + {(provided) => (
{ e.preventDefault(); + for (let field of ['name', 'url', 'icon'] as const) { + if (/^ +$/.test(formData[field])) { + createNotification({ + title: 'Error', + message: `Field cannot be empty: ${field}`, + }); + + return; + } + } + const createFormData = (): FormData => { const data = new FormData(); if (customIcon) { diff --git a/client/src/components/Bookmarks/Table/BookmarksTable.tsx b/client/src/components/Bookmarks/Table/BookmarksTable.tsx index 86f0db0..1b9c41f 100644 --- a/client/src/components/Bookmarks/Table/BookmarksTable.tsx +++ b/client/src/components/Bookmarks/Table/BookmarksTable.tsx @@ -1,25 +1,18 @@ -import { useState, useEffect, Fragment } from 'react'; -import { - DragDropContext, - Droppable, - Draggable, - DropResult, -} from 'react-beautiful-dnd'; +import { Fragment, useEffect, useState } from 'react'; +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { Bookmark, Category } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { bookmarkTemplate } from '../../../utility'; +import { TableActions } from '../../Actions/TableActions'; +import { Message, Table } from '../../UI'; // Redux -import { useDispatch, useSelector } from 'react-redux'; -import { State } from '../../../store/reducers'; -import { bindActionCreators } from 'redux'; -import { actionCreators } from '../../../store'; - // Typescript -import { Bookmark, Category } from '../../../interfaces'; - // UI -import { Message, Table } from '../../UI'; -import { TableActions } from '../../Actions/TableActions'; -import { bookmarkTemplate } from '../../../utility'; - interface Props { openFormForUpdating: (data: Category | Bookmark) => void; } @@ -48,7 +41,7 @@ export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => { }, [categoryInEdit]); // Drag and drop handler - const dragEndHanlder = (result: DropResult): void => { + const dragEndHandler = (result: DropResult): void => { if (config.useOrdering !== 'orderId') { createNotification({ title: 'Error', @@ -116,7 +109,7 @@ export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => { )} {categoryInEdit && ( - + {(provided) => (
void; } @@ -47,7 +40,7 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => { }, [categories]); // Drag and drop handler - const dragEndHanlder = (result: DropResult): void => { + const dragEndHandler = (result: DropResult): void => { if (config.useOrdering !== 'orderId') { createNotification({ title: 'Error', @@ -102,12 +95,12 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => { ) : (

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

)} - + {(provided) => (
{ ), ]); - // Search through bookmarks + // Search through apps const appCategory = { ...appCategories[0] }; appCategory.name = 'Search Results'; diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index 8d65579..27d7713 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -61,17 +61,22 @@ export const SearchBar = (props: Props): JSX.Element => { }; const searchHandler = (e: KeyboardEvent) => { - const { isLocal, search, query, isURL, sameTab } = searchParser( - inputRef.current.value - ); + const { + isLocal, + encodedURL, + primarySearch, + secondarySearch, + isURL, + sameTab, + rawQuery, + } = searchParser(inputRef.current.value); if (isLocal) { - // no additional encoding required for local search - setLocalSearch(inputRef.current.value); + setLocalSearch(encodedURL); } if (e.code === 'Enter' || e.code === 'NumpadEnter') { - if (!query.prefix) { + if (!primarySearch.prefix) { // Prefix not found -> emit notification createNotification({ title: 'Error', @@ -88,19 +93,21 @@ export const SearchBar = (props: Props): JSX.Element => { } else if (bookmarkSearchResult?.[0]?.bookmarks?.length) { redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab); } else { - // no local results -> search the internet with the default search provider - let template = query.template; + // no local results -> search the internet with the default search provider if query is not empty + if (!/^ *$/.test(rawQuery)) { + let template = primarySearch.template; - if (query.prefix === 'l') { - template = 'https://duckduckgo.com/?q='; + if (primarySearch.prefix === 'l') { + template = secondarySearch.template; + } + + const url = `${template}${encodedURL}`; + redirectUrl(url, sameTab); } - - const url = `${template}${search}`; - redirectUrl(url, sameTab); } } else { // Valid query -> redirect to search results - const url = `${query.template}${search}`; + const url = `${primarySearch.template}${encodedURL}`; redirectUrl(url, sameTab); } } else if (e.code === 'Escape') { diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx b/client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.tsx similarity index 66% rename from client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx rename to client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.tsx index 747be3b..8471fae 100644 --- a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx +++ b/client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.tsx @@ -9,11 +9,8 @@ import { actionCreators } from '../../../../store'; // Typescript import { Query } from '../../../../interfaces'; -// CSS -import classes from './CustomQueries.module.css'; - // UI -import { Modal, Icon, Button } from '../../../UI'; +import { Modal, Icon, Button, CompactTable, ActionIcons } from '../../../UI'; // Components import { QueriesForm } from './QueriesForm'; @@ -67,33 +64,27 @@ export const CustomQueries = (): JSX.Element => { )} -
-
- {customQueries.length > 0 && ( - - Name - Prefix - Actions - -
-
- )} - - {customQueries.map((q: Query, idx) => ( - - {q.name} - {q.prefix} - - updateHandler(q)}> - - - deleteHandler(q)}> - - - - - ))} -
+
+ {customQueries.length ? ( + + {customQueries.map((q: Query, idx) => ( + + {q.name} + {q.prefix} + + updateHandler(q)}> + + + deleteHandler(q)}> + + + + + ))} + + ) : ( + <> + )} -
+ ); }; diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx b/client/src/components/Settings/GeneralSettings/CustomQueries/QueriesForm.tsx similarity index 100% rename from client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx rename to client/src/components/Settings/GeneralSettings/CustomQueries/QueriesForm.tsx diff --git a/client/src/components/Settings/GeneralSettings/GeneralSettings.tsx b/client/src/components/Settings/GeneralSettings/GeneralSettings.tsx new file mode 100644 index 0000000..3bb3dc6 --- /dev/null +++ b/client/src/components/Settings/GeneralSettings/GeneralSettings.tsx @@ -0,0 +1,232 @@ +import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { GeneralForm, Query } from '../../../interfaces'; +import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; +import { generalSettingsTemplate, inputHandler } from '../../../utility'; +import { queries } from '../../../utility/searchQueries.json'; +import { Button, InputGroup, SettingsHeadline } from '../../UI'; +import { CustomQueries } from './CustomQueries/CustomQueries'; + +export const GeneralSettings = (): JSX.Element => { + const { + config: { loading, customQueries, config }, + apps: { categories: appCategories }, + bookmarks: { categories: bookmarkCategories }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { updateConfig, sortApps, sortCategories, sortBookmarks } = + bindActionCreators(actionCreators, dispatch); + + // Initial state + const [formData, setFormData] = useState( + generalSettingsTemplate + ); + + // Get config + useEffect(() => { + setFormData({ + ...config, + }); + }, [loading]); + + // Form handler + const formSubmitHandler = async (e: FormEvent) => { + e.preventDefault(); + + // Save settings + await updateConfig(formData); + + // Sort entities with new settings + if (formData.useOrdering !== config.useOrdering) { + sortCategories(); + + for (let { id } of appCategories) { + sortApps(id); + } + + for (let { id } of bookmarkCategories) { + sortBookmarks(id); + } + } + }; + + // Input handler + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + return ( + +
formSubmitHandler(e)} + style={{ marginBottom: '30px' }} + > + {/* === GENERAL OPTIONS === */} + + {/* SORT TYPE */} + + + + + + {/* === APPS OPTIONS === */} + + {/* PIN APPS */} + + + + + + {/* APPS OPPENING */} + + + + + + {/* === BOOKMARKS OPTIONS === */} + + {/* PIN CATEGORIES */} + + + + + + {/* BOOKMARKS OPPENING */} + + + + + + {/* === SEARCH OPTIONS === */} + + + + + + + {formData.defaultSearchProvider === 'l' && ( + + + + + Will be used when "Local search" is primary search provider and + there are not any local results + + + )} + + + + + + + + + + {/* CUSTOM QUERIES */} + + +
+ ); +}; diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.module.css b/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.module.css deleted file mode 100644 index 73297cc..0000000 --- a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.module.css +++ /dev/null @@ -1,30 +0,0 @@ -.QueriesGrid { - display: grid; - grid-template-columns: repeat(3, 1fr); -} - -.QueriesGrid span { - color: var(--color-primary); -} - -.QueriesGrid span:last-child { - margin-bottom: 10px; -} - -.ActionIcons { - display: flex; -} - -.ActionIcons svg { - width: 20px; -} - -.ActionIcons svg:hover { - cursor: pointer; -} - -.Separator { - grid-column: 1 / 4; - border-bottom: 1px solid var(--color-primary); - margin: 10px 0; -} diff --git a/client/src/components/Settings/SearchSettings/SearchSettings.tsx b/client/src/components/Settings/SearchSettings/SearchSettings.tsx deleted file mode 100644 index 9b057f5..0000000 --- a/client/src/components/Settings/SearchSettings/SearchSettings.tsx +++ /dev/null @@ -1,141 +0,0 @@ -// React -import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -// Typescript -import { Query, SearchForm } from '../../../interfaces'; - -// Components -import { CustomQueries } from './CustomQueries/CustomQueries'; - -// UI -import { Button, SettingsHeadline, InputGroup } from '../../UI'; - -// Utils -import { inputHandler, searchSettingsTemplate } from '../../../utility'; - -// Data -import { queries } from '../../../utility/searchQueries.json'; - -// Redux -import { State } from '../../../store/reducers'; -import { bindActionCreators } from 'redux'; -import { actionCreators } from '../../../store'; - -export const SearchSettings = (): JSX.Element => { - const { loading, customQueries, config } = useSelector( - (state: State) => state.config - ); - - const dispatch = useDispatch(); - const { updateConfig } = bindActionCreators(actionCreators, dispatch); - - // Initial state - const [formData, setFormData] = useState(searchSettingsTemplate); - - // Get config - useEffect(() => { - setFormData({ - ...config, - }); - }, [loading]); - - // Form handler - const formSubmitHandler = async (e: FormEvent) => { - e.preventDefault(); - - // Save settings - await updateConfig(formData); - }; - - // Input handler - const inputChangeHandler = ( - e: ChangeEvent, - options?: { isNumber?: boolean; isBool?: boolean } - ) => { - inputHandler({ - e, - options, - setStateHandler: setFormData, - state: formData, - }); - }; - - return ( - - {/* GENERAL SETTINGS */} -
formSubmitHandler(e)} - style={{ marginBottom: '30px' }} - > - - - - - - - - - - - - - - - - - - - - - - - - - {/* CUSTOM QUERIES */} - - -
- ); -}; diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index f9f5102..7a297c9 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -16,7 +16,7 @@ import { WeatherSettings } from './WeatherSettings/WeatherSettings'; import { UISettings } from './UISettings/UISettings'; import { AppDetails } from './AppDetails/AppDetails'; import { StyleSettings } from './StyleSettings/StyleSettings'; -import { SearchSettings } from './SearchSettings/SearchSettings'; +import { GeneralSettings } from './GeneralSettings/GeneralSettings'; import { DockerSettings } from './DockerSettings/DockerSettings'; import { ProtectedRoute } from '../Routing/ProtectedRoute'; @@ -59,8 +59,8 @@ export const Settings = (): JSX.Element => { component={WeatherSettings} /> { + const { + auth: { isAuthenticated }, + theme: { themeInEdit, userThemes }, + } = useSelector((state: State) => state); + + const { editTheme } = bindActionCreators(actionCreators, useDispatch()); + + const [showModal, toggleShowModal] = useState(false); + const [isInEdit, toggleIsInEdit] = useState(false); + + useEffect(() => { + if (themeInEdit) { + toggleIsInEdit(false); + toggleShowModal(true); + } + }, [themeInEdit]); + + useEffect(() => { + if (isInEdit && !userThemes.length) { + toggleIsInEdit(false); + toggleShowModal(false); + } + }, [userThemes]); + + return ( +
+ {/* MODALS */} + toggleShowModal(!showModal)} + cb={() => editTheme(null)} + > + {isInEdit ? ( + toggleShowModal(!showModal)} /> + ) : ( + toggleShowModal(!showModal)} /> + )} + + + {/* USER THEMES */} + + + {/* BUTTONS */} + {isAuthenticated && ( +
+ + + {themes.length ? ( + + ) : ( + <> + )} +
+ )} +
+ ); +}; diff --git a/client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.module.css b/client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.module.css new file mode 100644 index 0000000..893095f --- /dev/null +++ b/client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.module.css @@ -0,0 +1,6 @@ +.ColorsContainer { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-gap: 10px; + margin-bottom: 20px; +} diff --git a/client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.tsx b/client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.tsx new file mode 100644 index 0000000..84e1896 --- /dev/null +++ b/client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.tsx @@ -0,0 +1,152 @@ +import { ChangeEvent, FormEvent, useState, useEffect } from 'react'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; +import { State } from '../../../../store/reducers'; + +// UI +import { Button, InputGroup, ModalForm } from '../../../UI'; +import classes from './ThemeCreator.module.css'; + +// Other +import { Theme } from '../../../../interfaces'; + +interface Props { + modalHandler: () => void; +} + +export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => { + const { + theme: { activeTheme, themeInEdit }, + } = useSelector((state: State) => state); + + const { addTheme, updateTheme, editTheme } = bindActionCreators( + actionCreators, + useDispatch() + ); + + const [formData, setFormData] = useState({ + name: '', + isCustom: true, + colors: { + primary: '#ffffff', + accent: '#ffffff', + background: '#ffffff', + }, + }); + + useEffect(() => { + setFormData({ ...formData, colors: activeTheme.colors }); + }, [activeTheme]); + + useEffect(() => { + if (themeInEdit) { + setFormData(themeInEdit); + } + }, [themeInEdit]); + + const inputChangeHandler = (e: ChangeEvent) => { + const { name, value } = e.target; + + setFormData({ + ...formData, + [name]: value, + }); + }; + + const setColor = ({ + target: { value, name }, + }: ChangeEvent) => { + setFormData({ + ...formData, + colors: { + ...formData.colors, + [name]: value, + }, + }); + }; + + const closeModal = () => { + editTheme(null); + modalHandler(); + }; + + const formHandler = (e: FormEvent) => { + e.preventDefault(); + + if (!themeInEdit) { + addTheme(formData); + } else { + updateTheme(formData, themeInEdit.name); + } + + // close modal + closeModal(); + + // clear theme name + setFormData({ ...formData, name: '' }); + }; + + return ( + + + + inputChangeHandler(e)} + /> + + +
+ + + setColor(e)} + /> + + + + + setColor(e)} + /> + + + + + setColor(e)} + /> + +
+ + {!themeInEdit ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx b/client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx new file mode 100644 index 0000000..888e576 --- /dev/null +++ b/client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx @@ -0,0 +1,57 @@ +import { Fragment } from 'react'; + +// Redux +import { useSelector, useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { Theme } from '../../../../interfaces'; +import { actionCreators } from '../../../../store'; +import { State } from '../../../../store/reducers'; + +// Other +import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI'; + +interface Props { + modalHandler: () => void; +} + +export const ThemeEditor = (props: Props): JSX.Element => { + const { + theme: { userThemes }, + } = useSelector((state: State) => state); + + const { deleteTheme, editTheme } = bindActionCreators( + actionCreators, + useDispatch() + ); + + const updateHandler = (theme: Theme) => { + props.modalHandler(); + editTheme(theme); + }; + + const deleteHandler = (theme: Theme) => { + if (window.confirm(`Are you sure you want to delete this theme?`)) { + deleteTheme(theme.name); + } + }; + + return ( + {}} modalHandler={props.modalHandler}> + + {userThemes.map((t, idx) => ( + + {t.name} + + updateHandler(t)}> + + + deleteHandler(t)}> + + + + + ))} + + + ); +}; diff --git a/client/src/components/Settings/Themer/Themer.module.css b/client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.module.css similarity index 99% rename from client/src/components/Settings/Themer/Themer.module.css rename to client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.module.css index 986f6c5..65dbd44 100644 --- a/client/src/components/Settings/Themer/Themer.module.css +++ b/client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.module.css @@ -15,4 +15,4 @@ .ThemerGrid { grid-template-columns: 1fr 1fr 1fr; } -} \ No newline at end of file +} diff --git a/client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx b/client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx new file mode 100644 index 0000000..fbf5dab --- /dev/null +++ b/client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx @@ -0,0 +1,22 @@ +// Components +import { ThemePreview } from '../ThemePreview/ThemePreview'; + +// Other +import { Theme } from '../../../../interfaces'; +import classes from './ThemeGrid.module.css'; + +interface Props { + themes: Theme[]; +} + +export const ThemeGrid = ({ themes }: Props): JSX.Element => { + return ( +
+ {themes.map( + (theme: Theme, idx: number): JSX.Element => ( + + ) + )} +
+ ); +}; diff --git a/client/src/components/Settings/Themer/ThemePreview.tsx b/client/src/components/Settings/Themer/ThemePreview.tsx deleted file mode 100644 index eccf872..0000000 --- a/client/src/components/Settings/Themer/ThemePreview.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Theme } from '../../../interfaces/Theme'; -import classes from './ThemePreview.module.css'; - -interface Props { - theme: Theme; - applyTheme: Function; -} - -export const ThemePreview = (props: Props): JSX.Element => { - return ( -
props.applyTheme(props.theme.name)} - > -
-
-
-
-
-

{props.theme.name}

-
- ); -}; diff --git a/client/src/components/Settings/Themer/ThemePreview.module.css b/client/src/components/Settings/Themer/ThemePreview/ThemePreview.module.css similarity index 100% rename from client/src/components/Settings/Themer/ThemePreview.module.css rename to client/src/components/Settings/Themer/ThemePreview/ThemePreview.module.css diff --git a/client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx b/client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx new file mode 100644 index 0000000..ccbb42e --- /dev/null +++ b/client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx @@ -0,0 +1,38 @@ +// Redux +import { useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; + +// Other +import { Theme } from '../../../../interfaces/Theme'; +import classes from './ThemePreview.module.css'; + +interface Props { + theme: Theme; +} + +export const ThemePreview = ({ + theme: { colors, name }, +}: Props): JSX.Element => { + const { setTheme } = bindActionCreators(actionCreators, useDispatch()); + + return ( +
setTheme(colors)}> +
+
+
+
+
+

{name}

+
+ ); +}; diff --git a/client/src/components/Settings/Themer/Themer.tsx b/client/src/components/Settings/Themer/Themer.tsx index 61fbb92..ae2ac7d 100644 --- a/client/src/components/Settings/Themer/Themer.tsx +++ b/client/src/components/Settings/Themer/Themer.tsx @@ -4,31 +4,32 @@ import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { bindActionCreators } from 'redux'; import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; // Typescript import { Theme, ThemeSettingsForm } from '../../../interfaces'; // Components -import { ThemePreview } from './ThemePreview'; -import { Button, InputGroup, SettingsHeadline } from '../../UI'; +import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI'; +import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder'; +import { ThemeGrid } from './ThemeGrid/ThemeGrid'; // Other -import classes from './Themer.module.css'; -import { themes } from './themes.json'; -import { State } from '../../../store/reducers'; -import { inputHandler, themeSettingsTemplate } from '../../../utility'; +import { + inputHandler, + parseThemeToPAB, + themeSettingsTemplate, +} from '../../../utility'; export const Themer = (): JSX.Element => { const { auth: { isAuthenticated }, config: { loading, config }, + theme: { themes, userThemes }, } = useSelector((state: State) => state); const dispatch = useDispatch(); - const { setTheme, updateConfig } = bindActionCreators( - actionCreators, - dispatch - ); + const { updateConfig } = bindActionCreators(actionCreators, dispatch); // Initial state const [formData, setFormData] = useState( @@ -47,7 +48,7 @@ export const Themer = (): JSX.Element => { e.preventDefault(); // Save settings - await updateConfig(formData); + await updateConfig({ ...formData }); }; // Input handler @@ -63,31 +64,34 @@ export const Themer = (): JSX.Element => { }); }; + const customThemesEl = ( + + + + + ); + return ( - -
- {themes.map( - (theme: Theme, idx: number): JSX.Element => ( - - ) - )} -
+ + {!themes.length ? : } + + {!userThemes.length ? isAuthenticated && customThemesEl : customThemesEl} {isAuthenticated && (
- + diff --git a/client/src/components/Settings/UISettings/UISettings.tsx b/client/src/components/Settings/UISettings/UISettings.tsx index a78f475..3f276a5 100644 --- a/client/src/components/Settings/UISettings/UISettings.tsx +++ b/client/src/components/Settings/UISettings/UISettings.tsx @@ -2,27 +2,20 @@ import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { OtherSettingsForm } from '../../../interfaces'; +import { UISettingsForm } from '../../../interfaces'; import { actionCreators } from '../../../store'; import { State } from '../../../store/reducers'; -import { inputHandler, otherSettingsTemplate } from '../../../utility'; +import { inputHandler, uiSettingsTemplate } from '../../../utility'; import { Button, InputGroup, SettingsHeadline } from '../../UI'; export const UISettings = (): JSX.Element => { - const { - config: { loading, config }, - apps: { categories: appCategories }, - bookmarks: { categories: bookmarkCategories }, - } = useSelector((state: State) => state); + const { loading, config } = useSelector((state: State) => state.config); const dispatch = useDispatch(); - const { updateConfig, sortApps, sortCategories, sortBookmarks } = - bindActionCreators(actionCreators, dispatch); + const { updateConfig } = bindActionCreators(actionCreators, dispatch); // Initial state - const [formData, setFormData] = useState( - otherSettingsTemplate - ); + const [formData, setFormData] = useState(uiSettingsTemplate); // Get config useEffect(() => { @@ -40,19 +33,6 @@ export const UISettings = (): JSX.Element => { // Update local page title document.title = formData.customTitle; - - // Sort entities with new settings - if (formData.useOrdering !== config.useOrdering) { - sortCategories(); - - for (let { id } of appCategories) { - sortApps(id); - } - - for (let { id } of bookmarkCategories) { - sortBookmarks(id); - } - } }; // Input handler @@ -60,7 +40,7 @@ export const UISettings = (): JSX.Element => { e: ChangeEvent, options?: { isNumber?: boolean; isBool?: boolean } ) => { - inputHandler({ + inputHandler({ e, options, setStateHandler: setFormData, @@ -85,6 +65,36 @@ export const UISettings = (): JSX.Element => { /> + {/* === SEARCH OPTIONS === */} + + {/* HIDE SEARCHBAR */} + + + + + + {/* AUTOFOCUS SEARCHBAR */} + + + + + {/* === HEADER OPTIONS === */} {/* HIDE HEADER */} @@ -157,8 +167,8 @@ export const UISettings = (): JSX.Element => { onChange={(e) => inputChangeHandler(e)} /> - Greetings must be separated with semicolon. Only 4 messages can be - used + Greetings must be separated with semicolon. All 4 messages must be + filled, even if they are the same @@ -190,85 +200,8 @@ export const UISettings = (): JSX.Element => { Names must be separated with semicolon - {/* === BEAHVIOR OPTIONS === */} - - {/* PIN APPS */} - - - - - - {/* PIN CATEGORIES */} - - - - - - {/* SORT TYPE */} - - - - - - {/* APPS OPPENING */} - - - - - - {/* BOOKMARKS OPPENING */} - - - - - - {/* === MODULES OPTIONS === */} - + {/* === SECTIONS OPTIONS === */} + {/* HIDE APPS */} diff --git a/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx b/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx index 19ba7d4..a6819d8 100644 --- a/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx +++ b/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx @@ -82,6 +82,19 @@ export const WeatherSettings = (): JSX.Element => { }); }; + // Get user location + const getLocation = () => { + window.navigator.geolocation.getCurrentPosition( + ({ coords: { latitude, longitude } }) => { + setFormData({ + ...formData, + lat: latitude, + long: longitude, + }); + } + ); + }; + return ( formSubmitHandler(e)}> @@ -120,15 +133,8 @@ export const WeatherSettings = (): JSX.Element => { step="any" lang="en-150" /> - - You can use - - {' '} - latlong.net - + + Click to get current location diff --git a/client/src/components/Settings/settings.json b/client/src/components/Settings/settings.json index 49f0aee..75f6177 100644 --- a/client/src/components/Settings/settings.json +++ b/client/src/components/Settings/settings.json @@ -6,13 +6,8 @@ "authRequired": false }, { - "name": "Weather", - "dest": "/settings/weather", - "authRequired": true - }, - { - "name": "Search", - "dest": "/settings/search", + "name": "General", + "dest": "/settings/general", "authRequired": true }, { @@ -20,6 +15,11 @@ "dest": "/settings/interface", "authRequired": true }, + { + "name": "Weather", + "dest": "/settings/weather", + "authRequired": true + }, { "name": "Docker", "dest": "/settings/docker", diff --git a/client/src/components/UI/Forms/InputGroup/InputGroup.module.css b/client/src/components/UI/Forms/InputGroup/InputGroup.module.css index 93b74f1..f22dc5f 100644 --- a/client/src/components/UI/Forms/InputGroup/InputGroup.module.css +++ b/client/src/components/UI/Forms/InputGroup/InputGroup.module.css @@ -23,7 +23,7 @@ .InputGroup span { font-size: 12px; - color: var(--color-primary) + color: var(--color-primary); } .InputGroup span a { @@ -37,4 +37,8 @@ .InputGroup textarea { resize: none; height: 50vh; -} \ No newline at end of file +} + +.InputGroup input[type='color'] { + all: unset; +} diff --git a/client/src/components/UI/Icons/ActionIcons/ActionIcons.module.css b/client/src/components/UI/Icons/ActionIcons/ActionIcons.module.css new file mode 100644 index 0000000..d502f2e --- /dev/null +++ b/client/src/components/UI/Icons/ActionIcons/ActionIcons.module.css @@ -0,0 +1,11 @@ +.ActionIcons { + display: flex; +} + +.ActionIcons svg { + width: 20px; +} + +.ActionIcons svg:hover { + cursor: pointer; +} diff --git a/client/src/components/UI/Icons/ActionIcons/ActionIcons.tsx b/client/src/components/UI/Icons/ActionIcons/ActionIcons.tsx new file mode 100644 index 0000000..7b53035 --- /dev/null +++ b/client/src/components/UI/Icons/ActionIcons/ActionIcons.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; +import styles from './ActionIcons.module.css'; + +interface Props { + children: ReactNode; +} + +export const ActionIcons = ({ children }: Props): JSX.Element => { + return {children}; +}; diff --git a/client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx b/client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx index 2664b47..d28aff4 100644 --- a/client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx +++ b/client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx @@ -10,7 +10,7 @@ interface Props { } export const WeatherIcon = (props: Props): JSX.Element => { - const { theme } = useSelector((state: State) => state.theme); + const { activeTheme } = useSelector((state: State) => state.theme); const icon = props.isDay ? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day) @@ -18,7 +18,7 @@ export const WeatherIcon = (props: Props): JSX.Element => { useEffect(() => { const delay = setTimeout(() => { - const skycons = new Skycons({ color: theme.colors.accent }); + const skycons = new Skycons({ color: activeTheme.colors.accent }); skycons.add(`weather-icon`, icon); skycons.play(); }, 1); @@ -26,7 +26,7 @@ export const WeatherIcon = (props: Props): JSX.Element => { return () => { clearTimeout(delay); }; - }, [props.weatherStatusCode, icon, theme.colors.accent]); + }, [props.weatherStatusCode, icon, activeTheme.colors.accent]); return ; }; diff --git a/client/src/components/UI/Modal/Modal.tsx b/client/src/components/UI/Modal/Modal.tsx index 43fb5e9..3f2a6bd 100644 --- a/client/src/components/UI/Modal/Modal.tsx +++ b/client/src/components/UI/Modal/Modal.tsx @@ -6,24 +6,32 @@ interface Props { isOpen: boolean; setIsOpen: Function; children: ReactNode; + cb?: Function; } -export const Modal = (props: Props): JSX.Element => { +export const Modal = ({ + isOpen, + setIsOpen, + children, + cb, +}: Props): JSX.Element => { const modalRef = useRef(null); const modalClasses = [ classes.Modal, - props.isOpen ? classes.ModalOpen : classes.ModalClose, + isOpen ? classes.ModalOpen : classes.ModalClose, ].join(' '); const clickHandler = (e: MouseEvent) => { if (e.target === modalRef.current) { - props.setIsOpen(false); + setIsOpen(false); + + if (cb) cb(); } }; return (
- {props.children} + {children}
); }; diff --git a/client/src/components/UI/Tables/CompactTable/CompactTable.module.css b/client/src/components/UI/Tables/CompactTable/CompactTable.module.css new file mode 100644 index 0000000..1eeaa99 --- /dev/null +++ b/client/src/components/UI/Tables/CompactTable/CompactTable.module.css @@ -0,0 +1,16 @@ +.CompactTable { + display: grid; +} + +.CompactTable span { + color: var(--color-primary); +} + +.CompactTable span:last-child { + margin-bottom: 10px; +} + +.Separator { + border-bottom: 1px solid var(--color-primary); + margin: 10px 0; +} diff --git a/client/src/components/UI/Tables/CompactTable/CompactTable.tsx b/client/src/components/UI/Tables/CompactTable/CompactTable.tsx new file mode 100644 index 0000000..a5ae10a --- /dev/null +++ b/client/src/components/UI/Tables/CompactTable/CompactTable.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; +import classes from './CompactTable.module.css'; + +interface Props { + headers: string[]; + children?: ReactNode; +} + +export const CompactTable = ({ headers, children }: Props): JSX.Element => { + return ( +
+ {headers.map((h, idx) => ( + {h} + ))} + +
+ + {children} +
+ ); +}; diff --git a/client/src/components/UI/Table/Table.module.css b/client/src/components/UI/Tables/Table/Table.module.css similarity index 100% rename from client/src/components/UI/Table/Table.module.css rename to client/src/components/UI/Tables/Table/Table.module.css diff --git a/client/src/components/UI/Table/Table.tsx b/client/src/components/UI/Tables/Table/Table.tsx similarity index 100% rename from client/src/components/UI/Table/Table.tsx rename to client/src/components/UI/Tables/Table/Table.tsx diff --git a/client/src/components/UI/index.ts b/client/src/components/UI/index.ts index 23d5f73..179d982 100644 --- a/client/src/components/UI/index.ts +++ b/client/src/components/UI/index.ts @@ -1,10 +1,12 @@ -export * from './Table/Table'; +export * from './Tables/Table/Table'; +export * from './Tables/CompactTable/CompactTable'; export * from './Spinner/Spinner'; export * from './Notification/Notification'; export * from './Modal/Modal'; export * from './Layout/Layout'; export * from './Icons/Icon/Icon'; export * from './Icons/WeatherIcon/WeatherIcon'; +export * from './Icons/ActionIcons/ActionIcons'; export * from './Headlines/Headline/Headline'; export * from './Headlines/SectionHeadline/SectionHeadline'; export * from './Headlines/SettingsHeadline/SettingsHeadline'; diff --git a/client/src/interfaces/Config.ts b/client/src/interfaces/Config.ts index 97523c3..7034c63 100644 --- a/client/src/interfaces/Config.ts +++ b/client/src/interfaces/Config.ts @@ -19,6 +19,7 @@ export interface Config { hideEmptyCategories: boolean; hideSearch: boolean; defaultSearchProvider: string; + secondarySearchProvider: string; dockerApps: boolean; dockerHost: string; kubernetesApps: boolean; diff --git a/client/src/interfaces/Forms.ts b/client/src/interfaces/Forms.ts index 74244a3..7edf6b3 100644 --- a/client/src/interfaces/Forms.ts +++ b/client/src/interfaces/Forms.ts @@ -8,31 +8,32 @@ export interface WeatherForm { weatherData: WeatherData; } -export interface SearchForm { - hideSearch: boolean; +export interface GeneralForm { defaultSearchProvider: string; + secondarySearchProvider: string; searchSameTab: boolean; - disableAutofocus: boolean; -} - -export interface OtherSettingsForm { - customTitle: string; pinAppsByDefault: boolean; pinBookmarksByDefault: boolean; pinCategoriesByDefault: boolean; + useOrdering: string; + appsSameTab: boolean; + bookmarksSameTab: boolean; +} + +export interface UISettingsForm { + customTitle: string; hideHeader: boolean; hideApps: boolean; hideBookmarks: boolean; hideEmptyCategories: boolean; - useOrdering: string; - appsSameTab: boolean; - bookmarksSameTab: boolean; useAmericanDate: boolean; greetingsSchema: string; daySchema: string; monthSchema: string; showTime: boolean; hideDate: boolean; + hideSearch: boolean; + disableAutofocus: boolean; } export interface DockerSettingsForm { diff --git a/client/src/interfaces/SearchResult.ts b/client/src/interfaces/SearchResult.ts index 3d6c8ae..992b392 100644 --- a/client/src/interfaces/SearchResult.ts +++ b/client/src/interfaces/SearchResult.ts @@ -4,6 +4,8 @@ export interface SearchResult { isLocal: boolean; isURL: boolean; sameTab: boolean; - search: string; - query: Query; + encodedURL: string; + primarySearch: Query; + secondarySearch: Query; + rawQuery: string; } diff --git a/client/src/interfaces/Theme.ts b/client/src/interfaces/Theme.ts index 9753427..e319ef1 100644 --- a/client/src/interfaces/Theme.ts +++ b/client/src/interfaces/Theme.ts @@ -1,8 +1,11 @@ +export interface ThemeColors { + background: string; + primary: string; + accent: string; +} + export interface Theme { name: string; - colors: { - background: string; - primary: string; - accent: string; - } -} \ No newline at end of file + colors: ThemeColors; + isCustom: boolean; +} diff --git a/client/src/store/action-creators/config.ts b/client/src/store/action-creators/config.ts index 6b516f7..d019876 100644 --- a/client/src/store/action-creators/config.ts +++ b/client/src/store/action-creators/config.ts @@ -7,7 +7,7 @@ import { UpdateConfigAction, UpdateQueryAction, } from '../actions/config'; -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; import { ApiResponse, Config, Query } from '../../interfaces'; import { ActionType } from '../action-types'; import { storeUIConfig, applyAuth } from '../../utility'; @@ -103,7 +103,15 @@ export const addQuery = payload: res.data.data, }); } catch (err) { - console.log(err); + const error = err as AxiosError<{ error: string }>; + + dispatch({ + type: ActionType.createNotification, + payload: { + title: 'Error', + message: error.response?.data.error, + }, + }); } }; diff --git a/client/src/store/action-creators/theme.ts b/client/src/store/action-creators/theme.ts index 8eb6fef..e0199b2 100644 --- a/client/src/store/action-creators/theme.ts +++ b/client/src/store/action-creators/theme.ts @@ -1,30 +1,128 @@ import { Dispatch } from 'redux'; -import { SetThemeAction } from '../actions/theme'; +import { + AddThemeAction, + DeleteThemeAction, + EditThemeAction, + FetchThemesAction, + SetThemeAction, + UpdateThemeAction, +} from '../actions/theme'; import { ActionType } from '../action-types'; -import { Theme } from '../../interfaces/Theme'; -import { themes } from '../../components/Settings/Themer/themes.json'; +import { Theme, ApiResponse, ThemeColors } from '../../interfaces'; +import { applyAuth, parseThemeToPAB } from '../../utility'; +import axios, { AxiosError } from 'axios'; export const setTheme = - (name: string, remeberTheme: boolean = true) => + (colors: ThemeColors, remeberTheme: boolean = true) => (dispatch: Dispatch) => { - const theme = themes.find((theme) => theme.name === name); + if (remeberTheme) { + localStorage.setItem('theme', parseThemeToPAB(colors)); + } - if (theme) { - if (remeberTheme) { - localStorage.setItem('theme', name); - } + for (const [key, value] of Object.entries(colors)) { + document.body.style.setProperty(`--color-${key}`, value); + } - loadTheme(theme); + dispatch({ + type: ActionType.setTheme, + payload: colors, + }); + }; + +export const fetchThemes = + () => async (dispatch: Dispatch) => { + try { + const res = await axios.get>('/api/themes'); dispatch({ - type: ActionType.setTheme, - payload: theme, + type: ActionType.fetchThemes, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; + +export const addTheme = + (theme: Theme) => async (dispatch: Dispatch) => { + try { + const res = await axios.post>('/api/themes', theme, { + headers: applyAuth(), + }); + + dispatch({ + type: ActionType.addTheme, + payload: res.data.data, + }); + + dispatch({ + type: ActionType.createNotification, + payload: { + title: 'Success', + message: 'Theme added', + }, + }); + } catch (err) { + const error = err as AxiosError<{ error: string }>; + + dispatch({ + type: ActionType.createNotification, + payload: { + title: 'Error', + message: error.response?.data.error, + }, }); } }; -export const loadTheme = (theme: Theme): void => { - for (const [key, value] of Object.entries(theme.colors)) { - document.body.style.setProperty(`--color-${key}`, value); - } -}; +export const deleteTheme = + (name: string) => async (dispatch: Dispatch) => { + try { + const res = await axios.delete>( + `/api/themes/${name}`, + { headers: applyAuth() } + ); + + dispatch({ + type: ActionType.deleteTheme, + payload: res.data.data, + }); + + dispatch({ + type: ActionType.createNotification, + payload: { + title: 'Success', + message: 'Theme deleted', + }, + }); + } catch (err) { + console.log(err); + } + }; + +export const editTheme = + (theme: Theme | null) => (dispatch: Dispatch) => { + dispatch({ + type: ActionType.editTheme, + payload: theme, + }); + }; + +export const updateTheme = + (theme: Theme, originalName: string) => + async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/themes/${originalName}`, + theme, + { headers: applyAuth() } + ); + + dispatch({ + type: ActionType.updateTheme, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; diff --git a/client/src/store/action-types/index.ts b/client/src/store/action-types/index.ts index 4be159f..c9ba812 100644 --- a/client/src/store/action-types/index.ts +++ b/client/src/store/action-types/index.ts @@ -1,6 +1,11 @@ export enum ActionType { // THEME setTheme = 'SET_THEME', + fetchThemes = 'FETCH_THEMES', + addTheme = 'ADD_THEME', + deleteTheme = 'DELETE_THEME', + updateTheme = 'UPDATE_THEME', + editTheme = 'EDIT_THEME', // CONFIG getConfig = 'GET_CONFIG', updateConfig = 'UPDATE_CONFIG', diff --git a/client/src/store/actions/index.ts b/client/src/store/actions/index.ts index a139999..61caa5c 100644 --- a/client/src/store/actions/index.ts +++ b/client/src/store/actions/index.ts @@ -35,11 +35,23 @@ import { UpdateQueryAction, } from './config'; import { ClearNotificationAction, CreateNotificationAction } from './notification'; -import { SetThemeAction } from './theme'; +import { + AddThemeAction, + DeleteThemeAction, + EditThemeAction, + FetchThemesAction, + SetThemeAction, + UpdateThemeAction, +} from './theme'; export type Action = // Theme | SetThemeAction + | FetchThemesAction + | AddThemeAction + | DeleteThemeAction + | UpdateThemeAction + | EditThemeAction // Config | GetConfigAction | UpdateConfigAction diff --git a/client/src/store/actions/theme.ts b/client/src/store/actions/theme.ts index 036b1a3..e3da5b4 100644 --- a/client/src/store/actions/theme.ts +++ b/client/src/store/actions/theme.ts @@ -1,7 +1,32 @@ import { ActionType } from '../action-types'; -import { Theme } from '../../interfaces'; +import { Theme, ThemeColors } from '../../interfaces'; export interface SetThemeAction { type: ActionType.setTheme; + payload: ThemeColors; +} + +export interface FetchThemesAction { + type: ActionType.fetchThemes; + payload: Theme[]; +} + +export interface AddThemeAction { + type: ActionType.addTheme; payload: Theme; } + +export interface DeleteThemeAction { + type: ActionType.deleteTheme; + payload: Theme[]; +} + +export interface UpdateThemeAction { + type: ActionType.updateTheme; + payload: Theme[]; +} + +export interface EditThemeAction { + type: ActionType.editTheme; + payload: Theme | null; +} diff --git a/client/src/store/reducers/app.ts b/client/src/store/reducers/app.ts index 9ebd16a..2d4b15c 100644 --- a/client/src/store/reducers/app.ts +++ b/client/src/store/reducers/app.ts @@ -1,12 +1,10 @@ -import { App, Category } from '../../interfaces'; +import { App } from '../../interfaces'; import { sortData } from '../../utility'; import { ActionType } from '../action-types'; import { Action } from '../actions'; import { categoriesReducer, CategoriesState } from './category'; interface AppsState extends CategoriesState { - categories: Category[]; - categoryInEdit: Category | null; appInEdit: App | null; } diff --git a/client/src/store/reducers/bookmark.ts b/client/src/store/reducers/bookmark.ts index 7accfe3..c26138a 100644 --- a/client/src/store/reducers/bookmark.ts +++ b/client/src/store/reducers/bookmark.ts @@ -1,12 +1,10 @@ -import { Bookmark, Category } from '../../interfaces'; +import { Bookmark } from '../../interfaces'; import { sortData } from '../../utility'; import { ActionType } from '../action-types'; import { Action } from '../actions'; import { categoriesReducer, CategoriesState } from './category'; interface BookmarksState extends CategoriesState { - categories: Category[]; - categoryInEdit: Category | null; bookmarkInEdit: Bookmark | null; } diff --git a/client/src/store/reducers/category.ts b/client/src/store/reducers/category.ts index 93131f2..e8d367a 100644 --- a/client/src/store/reducers/category.ts +++ b/client/src/store/reducers/category.ts @@ -7,6 +7,7 @@ export interface CategoriesState { loading: boolean; errors: string | undefined; categories: Category[]; + categoryInEdit: Category | null; type: string; } diff --git a/client/src/store/reducers/theme.ts b/client/src/store/reducers/theme.ts index 6db29fe..b4fb55c 100644 --- a/client/src/store/reducers/theme.ts +++ b/client/src/store/reducers/theme.ts @@ -1,20 +1,30 @@ import { Action } from '../actions'; import { ActionType } from '../action-types'; import { Theme } from '../../interfaces/Theme'; +import { arrayPartition, parsePABToTheme } from '../../utility'; interface ThemeState { - theme: Theme; + activeTheme: Theme; + themes: Theme[]; + userThemes: Theme[]; + themeInEdit: Theme | null; } +const savedTheme = localStorage.theme + ? parsePABToTheme(localStorage.theme) + : parsePABToTheme('#effbff;#6ee2ff;#242b33'); + const initialState: ThemeState = { - theme: { - name: 'tron', + activeTheme: { + name: 'main', + isCustom: false, colors: { - background: '#242B33', - primary: '#EFFBFF', - accent: '#6EE2FF', + ...savedTheme, }, }, + themes: [], + userThemes: [], + themeInEdit: null, }; export const themeReducer = ( @@ -22,8 +32,56 @@ export const themeReducer = ( action: Action ): ThemeState => { switch (action.type) { - case ActionType.setTheme: - return { theme: action.payload }; + case ActionType.setTheme: { + return { + ...state, + activeTheme: { + ...state.activeTheme, + colors: action.payload, + }, + }; + } + + case ActionType.fetchThemes: { + const [themes, userThemes] = arrayPartition( + action.payload, + (e) => !e.isCustom + ); + + return { + ...state, + themes, + userThemes, + }; + } + + case ActionType.addTheme: { + return { + ...state, + userThemes: [...state.userThemes, action.payload], + }; + } + + case ActionType.deleteTheme: { + return { + ...state, + userThemes: action.payload, + }; + } + + case ActionType.editTheme: { + return { + ...state, + themeInEdit: action.payload, + }; + } + + case ActionType.updateTheme: { + return { + ...state, + userThemes: action.payload, + }; + } default: return state; diff --git a/client/src/types/ConfigFormData.ts b/client/src/types/ConfigFormData.ts index a67d8af..5f1c3e5 100644 --- a/client/src/types/ConfigFormData.ts +++ b/client/src/types/ConfigFormData.ts @@ -1,14 +1,14 @@ import { DockerSettingsForm, - OtherSettingsForm, - SearchForm, + UISettingsForm, + GeneralForm, ThemeSettingsForm, WeatherForm, } from '../interfaces'; export type ConfigFormData = | WeatherForm - | SearchForm + | GeneralForm | DockerSettingsForm - | OtherSettingsForm + | UISettingsForm | ThemeSettingsForm; diff --git a/client/src/utility/arrayPartition.ts b/client/src/utility/arrayPartition.ts new file mode 100644 index 0000000..eae67c3 --- /dev/null +++ b/client/src/utility/arrayPartition.ts @@ -0,0 +1,11 @@ +export const arrayPartition = ( + arr: T[], + isValid: (e: T) => boolean +): T[][] => { + let pass: T[] = []; + let fail: T[] = []; + + arr.forEach((e) => (isValid(e) ? pass : fail).push(e)); + + return [pass, fail]; +}; diff --git a/client/src/utility/index.ts b/client/src/utility/index.ts index 7358da4..0d002bc 100644 --- a/client/src/utility/index.ts +++ b/client/src/utility/index.ts @@ -12,3 +12,5 @@ export * from './parseTime'; export * from './decodeToken'; export * from './applyAuth'; export * from './escapeRegex'; +export * from './parseTheme'; +export * from './arrayPartition'; diff --git a/client/src/utility/parseTheme.ts b/client/src/utility/parseTheme.ts new file mode 100644 index 0000000..eaa800f --- /dev/null +++ b/client/src/utility/parseTheme.ts @@ -0,0 +1,20 @@ +import { ThemeColors } from '../interfaces'; + +// parse theme in PAB (primary;accent;background) format to theme colors object +export const parsePABToTheme = (themeStr: string): ThemeColors => { + const [primary, accent, background] = themeStr.split(';'); + + return { + primary, + accent, + background, + }; +}; + +export const parseThemeToPAB = ({ + primary: p, + accent: a, + background: b, +}: ThemeColors): string => { + return `${p};${a};${b}`; +}; diff --git a/client/src/utility/searchParser.ts b/client/src/utility/searchParser.ts index 82ce28c..92b80ac 100644 --- a/client/src/utility/searchParser.ts +++ b/client/src/utility/searchParser.ts @@ -1,5 +1,5 @@ import { queries } from './searchQueries.json'; -import { Query, SearchResult } from '../interfaces'; +import { SearchResult } from '../interfaces'; import { store } from '../store/store'; import { isUrlOrIp } from '.'; @@ -8,12 +8,18 @@ export const searchParser = (searchQuery: string): SearchResult => { isLocal: false, isURL: false, sameTab: false, - search: '', - query: { + encodedURL: '', + primarySearch: { name: '', prefix: '', template: '', }, + secondarySearch: { + name: '', + prefix: '', + template: '', + }, + rawQuery: searchQuery, }; const { customQueries, config } = store.getState().config; @@ -24,20 +30,26 @@ export const searchParser = (searchQuery: string): SearchResult => { // Match prefix and query const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i); + // Extract prefix const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider; - const search = splitQuery + // Encode url + const encodedURL = splitQuery ? encodeURIComponent(splitQuery[2]) : encodeURIComponent(searchQuery); - const query = [...queries, ...customQueries].find( - (q: Query) => q.prefix === prefix - ); + // Find primary search engine template + const findProvider = (prefix: string) => { + return [...queries, ...customQueries].find((q) => q.prefix === prefix); + }; - // If search provider was found - if (query) { - result.query = query; - result.search = search; + const primarySearch = findProvider(prefix); + const secondarySearch = findProvider(config.secondarySearchProvider); + + // If search providers were found + if (primarySearch) { + result.primarySearch = primarySearch; + result.encodedURL = encodedURL; if (prefix === 'l') { result.isLocal = true; @@ -45,6 +57,10 @@ export const searchParser = (searchQuery: string): SearchResult => { result.sameTab = config.searchSameTab; } + if (secondarySearch) { + result.secondarySearch = secondarySearch; + } + return result; } diff --git a/client/src/utility/templateObjects/configTemplate.ts b/client/src/utility/templateObjects/configTemplate.ts index c59412d..c208210 100644 --- a/client/src/utility/templateObjects/configTemplate.ts +++ b/client/src/utility/templateObjects/configTemplate.ts @@ -19,6 +19,7 @@ export const configTemplate: Config = { hideEmptyCategories: true, hideSearch: false, defaultSearchProvider: 'l', + secondarySearchProvider: 'd', dockerApps: false, dockerHost: 'localhost', kubernetesApps: false, diff --git a/client/src/utility/templateObjects/settingsTemplate.ts b/client/src/utility/templateObjects/settingsTemplate.ts index 1a898e0..3d6a02a 100644 --- a/client/src/utility/templateObjects/settingsTemplate.ts +++ b/client/src/utility/templateObjects/settingsTemplate.ts @@ -1,17 +1,11 @@ -import { DockerSettingsForm, OtherSettingsForm, SearchForm, ThemeSettingsForm, WeatherForm } from '../../interfaces'; +import { DockerSettingsForm, GeneralForm, ThemeSettingsForm, UISettingsForm, WeatherForm } from '../../interfaces'; -export const otherSettingsTemplate: OtherSettingsForm = { +export const uiSettingsTemplate: UISettingsForm = { customTitle: document.title, - pinAppsByDefault: true, - pinBookmarksByDefault: true, - pinCategoriesByDefault: true, hideHeader: false, hideApps: false, hideBookmarks: false, hideEmptyCategories: true, - useOrdering: 'createdAt', - appsSameTab: false, - bookmarksSameTab: false, useAmericanDate: false, greetingsSchema: 'Good evening!;Good afternoon!;Good morning!;Good night!', daySchema: 'Sunday;Monday;Tuesday;Wednesday;Thursday;Friday;Saturday', @@ -19,6 +13,8 @@ export const otherSettingsTemplate: OtherSettingsForm = { 'January;February;March;April;May;June;July;August;September;October;November;December', showTime: false, hideDate: false, + hideSearch: false, + disableAutofocus: false, }; export const weatherSettingsTemplate: WeatherForm = { @@ -29,11 +25,16 @@ export const weatherSettingsTemplate: WeatherForm = { weatherData: 'cloud', }; -export const searchSettingsTemplate: SearchForm = { - hideSearch: false, +export const generalSettingsTemplate: GeneralForm = { searchSameTab: false, defaultSearchProvider: 'l', - disableAutofocus: false, + secondarySearchProvider: 'd', + pinAppsByDefault: true, + pinBookmarksByDefault: true, + pinCategoriesByDefault: true, + useOrdering: 'createdAt', + appsSameTab: false, + bookmarksSameTab: false, }; export const dockerSettingsTemplate: DockerSettingsForm = { diff --git a/controllers/queries/addQuery.js b/controllers/queries/addQuery.js index cd61c67..9db41a8 100644 --- a/controllers/queries/addQuery.js +++ b/controllers/queries/addQuery.js @@ -1,4 +1,5 @@ const asyncWrapper = require('../../middleware/asyncWrapper'); +const ErrorResponse = require('../../utils/ErrorResponse'); const File = require('../../utils/File'); // @desc Add custom search query @@ -8,6 +9,12 @@ const addQuery = asyncWrapper(async (req, res, next) => { const file = new File('data/customQueries.json'); let content = JSON.parse(file.read()); + const prefixes = content.queries.map((q) => q.prefix); + + if (prefixes.includes(req.body.prefix)) { + return next(new ErrorResponse('Prefix must be unique', 400)); + } + // Add new query content.queries.push(req.body); file.write(content, true); diff --git a/controllers/themes/addTheme.js b/controllers/themes/addTheme.js new file mode 100644 index 0000000..1d2cf0c --- /dev/null +++ b/controllers/themes/addTheme.js @@ -0,0 +1,28 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const ErrorResponse = require('../../utils/ErrorResponse'); +const File = require('../../utils/File'); + +// @desc Create new theme +// @route POST /api/themes +// @access Private +const addTheme = asyncWrapper(async (req, res, next) => { + const file = new File('data/themes.json'); + let content = JSON.parse(file.read()); + + const themeNames = content.themes.map((t) => t.name); + + if (themeNames.includes(req.body.name)) { + return next(new ErrorResponse('Name must be unique', 400)); + } + + // Add new theme + content.themes.push(req.body); + file.write(content, true); + + res.status(201).json({ + success: true, + data: req.body, + }); +}); + +module.exports = addTheme; diff --git a/controllers/themes/deleteTheme.js b/controllers/themes/deleteTheme.js new file mode 100644 index 0000000..668b0e1 --- /dev/null +++ b/controllers/themes/deleteTheme.js @@ -0,0 +1,22 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); + +// @desc Delete theme +// @route DELETE /api/themes/:name +// @access Public +const deleteTheme = asyncWrapper(async (req, res, next) => { + const file = new File('data/themes.json'); + let content = JSON.parse(file.read()); + + content.themes = content.themes.filter((t) => t.name != req.params.name); + file.write(content, true); + + const userThemes = content.themes.filter((t) => t.isCustom); + + res.status(200).json({ + success: true, + data: userThemes, + }); +}); + +module.exports = deleteTheme; diff --git a/controllers/themes/getThemes.js b/controllers/themes/getThemes.js new file mode 100644 index 0000000..af3ff13 --- /dev/null +++ b/controllers/themes/getThemes.js @@ -0,0 +1,17 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); + +// @desc Get themes file +// @route GET /api/themes +// @access Public +const getThemes = asyncWrapper(async (req, res, next) => { + const file = new File('data/themes.json'); + const content = JSON.parse(file.read()); + + res.status(200).json({ + success: true, + data: content.themes, + }); +}); + +module.exports = getThemes; diff --git a/controllers/themes/index.js b/controllers/themes/index.js new file mode 100644 index 0000000..b87adb5 --- /dev/null +++ b/controllers/themes/index.js @@ -0,0 +1,6 @@ +module.exports = { + getThemes: require('./getThemes'), + addTheme: require('./addTheme'), + deleteTheme: require('./deleteTheme'), + updateTheme: require('./updateTheme'), +}; diff --git a/controllers/themes/updateTheme.js b/controllers/themes/updateTheme.js new file mode 100644 index 0000000..e3a048c --- /dev/null +++ b/controllers/themes/updateTheme.js @@ -0,0 +1,32 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const File = require('../../utils/File'); + +// @desc Update theme +// @route PUT /api/themes/:name +// @access Public +const updateTheme = asyncWrapper(async (req, res, next) => { + const file = new File('data/themes.json'); + let content = JSON.parse(file.read()); + + let themeIdx = content.themes.findIndex((t) => t.name == req.params.name); + + // theme found + if (themeIdx > -1) { + content.themes = [ + ...content.themes.slice(0, themeIdx), + req.body, + ...content.themes.slice(themeIdx + 1), + ]; + } + + file.write(content, true); + + const userThemes = content.themes.filter((t) => t.isCustom); + + res.status(200).json({ + success: true, + data: userThemes, + }); +}); + +module.exports = updateTheme; diff --git a/routes/queries.js b/routes/queries.js index 2262611..ec15790 100644 --- a/routes/queries.js +++ b/routes/queries.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); // middleware -const { auth, requireAuth } = require('../middleware'); +const { auth, requireAuth, requireBody } = require('../middleware'); const { getQueries, @@ -11,7 +11,16 @@ const { updateQuery, } = require('../controllers/queries/'); -router.route('/').post(auth, requireAuth, addQuery).get(getQueries); +router + .route('/') + .post( + auth, + requireAuth, + requireBody(['name', 'prefix', 'template']), + addQuery + ) + .get(getQueries); + router .route('/:prefix') .delete(auth, requireAuth, deleteQuery) diff --git a/routes/themes.js b/routes/themes.js new file mode 100644 index 0000000..5886aae --- /dev/null +++ b/routes/themes.js @@ -0,0 +1,29 @@ +const express = require('express'); +const router = express.Router(); + +// middleware +const { auth, requireAuth, requireBody } = require('../middleware'); + +const { + getThemes, + addTheme, + deleteTheme, + updateTheme, +} = require('../controllers/themes/'); + +router + .route('/') + .get(getThemes) + .post( + auth, + requireAuth, + requireBody(['name', 'colors', 'isCustom']), + addTheme + ); + +router + .route('/:name') + .delete(auth, requireAuth, deleteTheme) + .put(auth, requireAuth, updateTheme); + +module.exports = router; diff --git a/utils/Logger.js b/utils/Logger.js index 1d1deef..411b39f 100644 --- a/utils/Logger.js +++ b/utils/Logger.js @@ -1,6 +1,6 @@ class Logger { log(message, level = 'INFO') { - console.log(`[${this.generateTimestamp()}] [${level}] ${message}`) + console.log(`[${this.generateTimestamp()}] [${level}] ${message}`); } generateTimestamp() { @@ -20,7 +20,9 @@ class Logger { // Timezone const tz = -d.getTimezoneOffset() / 60; - return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}.${miliseconds} UTC${tz >= 0 ? '+' + tz : tz}`; + return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}.${miliseconds} UTC${ + tz >= 0 ? '+' + tz : tz + }`; } parseDate(date, ms = false) { @@ -36,4 +38,4 @@ class Logger { } } -module.exports = Logger; \ No newline at end of file +module.exports = Logger; diff --git a/utils/init/index.js b/utils/init/index.js index 4ff3e3b..66c97cf 100644 --- a/utils/init/index.js +++ b/utils/init/index.js @@ -1,11 +1,13 @@ const initConfig = require('./initConfig'); const initFiles = require('./initFiles'); const initDockerSecrets = require('./initDockerSecrets'); +const normalizeTheme = require('./normalizeTheme'); const initApp = async () => { initDockerSecrets(); await initFiles(); await initConfig(); + await normalizeTheme(); }; module.exports = initApp; diff --git a/utils/init/initialConfig.json b/utils/init/initialConfig.json index a01ff1e..9b9a69a 100644 --- a/utils/init/initialConfig.json +++ b/utils/init/initialConfig.json @@ -17,6 +17,7 @@ "hideEmptyCategories": true, "hideSearch": false, "defaultSearchProvider": "l", + "secondarySearchProvider": "d", "dockerApps": false, "dockerHost": "localhost", "kubernetesApps": false, diff --git a/utils/init/initialFiles.json b/utils/init/initialFiles.json index 42354d7..83d370a 100644 --- a/utils/init/initialFiles.json +++ b/utils/init/initialFiles.json @@ -27,6 +27,166 @@ "queries": [] }, "isJSON": true + }, + { + "name": "themes.json", + "msg": { + "created": "Created default theme file", + "found": "Found theme file" + }, + "paths": { + "src": "../../data", + "dest": "../../data" + }, + "template": { + "themes": [ + { + "name": "blackboard", + "colors": { + "background": "#1a1a1a", + "primary": "#FFFDEA", + "accent": "#5c5c5c" + }, + "isCustom": false + }, + { + "name": "gazette", + "colors": { + "background": "#F2F7FF", + "primary": "#000000", + "accent": "#5c5c5c" + }, + "isCustom": false + }, + { + "name": "espresso", + "colors": { + "background": "#21211F", + "primary": "#D1B59A", + "accent": "#4E4E4E" + }, + "isCustom": false + }, + { + "name": "cab", + "colors": { + "background": "#F6D305", + "primary": "#1F1F1F", + "accent": "#424242" + }, + "isCustom": false + }, + { + "name": "cloud", + "colors": { + "background": "#f1f2f0", + "primary": "#35342f", + "accent": "#37bbe4" + }, + "isCustom": false + }, + { + "name": "lime", + "colors": { + "background": "#263238", + "primary": "#AABBC3", + "accent": "#aeea00" + }, + "isCustom": false + }, + { + "name": "white", + "colors": { + "background": "#ffffff", + "primary": "#222222", + "accent": "#dddddd" + }, + "isCustom": false + }, + { + "name": "tron", + "colors": { + "background": "#242B33", + "primary": "#EFFBFF", + "accent": "#6EE2FF" + }, + "isCustom": false + }, + { + "name": "blues", + "colors": { + "background": "#2B2C56", + "primary": "#EFF1FC", + "accent": "#6677EB" + }, + "isCustom": false + }, + { + "name": "passion", + "colors": { + "background": "#f5f5f5", + "primary": "#12005e", + "accent": "#8e24aa" + }, + "isCustom": false + }, + { + "name": "chalk", + "colors": { + "background": "#263238", + "primary": "#AABBC3", + "accent": "#FF869A" + }, + "isCustom": false + }, + { + "name": "paper", + "colors": { + "background": "#F8F6F1", + "primary": "#4C432E", + "accent": "#AA9A73" + }, + "isCustom": false + }, + { + "name": "neon", + "colors": { + "background": "#091833", + "primary": "#EFFBFF", + "accent": "#ea00d9" + }, + "isCustom": false + }, + { + "name": "pumpkin", + "colors": { + "background": "#2d3436", + "primary": "#EFFBFF", + "accent": "#ffa500" + }, + "isCustom": false + }, + { + "name": "onedark", + "colors": { + "background": "#282c34", + "primary": "#dfd9d6", + "accent": "#98c379" + }, + "isCustom": false + }, + { + "name": "mint", + "colors": { + "background": "#282525", + "primary": "#d9d9d9", + "accent": "#50fbc2" + }, + "isCustom": false + } + ] + }, + "isJSON": true } ] } diff --git a/utils/init/normalizeTheme.js b/utils/init/normalizeTheme.js new file mode 100644 index 0000000..272db52 --- /dev/null +++ b/utils/init/normalizeTheme.js @@ -0,0 +1,28 @@ +const { readFile, writeFile } = require('fs/promises'); + +const normalizeTheme = async () => { + // open main config file + const configFile = await readFile('data/config.json', 'utf8'); + const config = JSON.parse(configFile); + + // open default themes file + const themesFile = await readFile('utils/init/themes.json', 'utf8'); + const { themes } = JSON.parse(themesFile); + + // find theme + const theme = themes.find((t) => t.name === config.defaultTheme); + + if (theme) { + // save theme in new format + // PAB - primary;accent;background + const { primary: p, accent: a, background: b } = theme.colors; + const normalizedTheme = `${p};${a};${b}`; + + await writeFile( + 'data/config.json', + JSON.stringify({ ...config, defaultTheme: normalizedTheme }) + ); + } +}; + +module.exports = normalizeTheme; diff --git a/client/src/components/Settings/Themer/themes.json b/utils/init/themes.json similarity index 76% rename from client/src/components/Settings/Themer/themes.json rename to utils/init/themes.json index f3b12bd..867fe7e 100644 --- a/client/src/components/Settings/Themer/themes.json +++ b/utils/init/themes.json @@ -6,7 +6,8 @@ "background": "#1a1a1a", "primary": "#FFFDEA", "accent": "#5c5c5c" - } + }, + "isCustom": false }, { "name": "gazette", @@ -14,7 +15,8 @@ "background": "#F2F7FF", "primary": "#000000", "accent": "#5c5c5c" - } + }, + "isCustom": false }, { "name": "espresso", @@ -22,7 +24,8 @@ "background": "#21211F", "primary": "#D1B59A", "accent": "#4E4E4E" - } + }, + "isCustom": false }, { "name": "cab", @@ -30,7 +33,8 @@ "background": "#F6D305", "primary": "#1F1F1F", "accent": "#424242" - } + }, + "isCustom": false }, { "name": "cloud", @@ -38,7 +42,8 @@ "background": "#f1f2f0", "primary": "#35342f", "accent": "#37bbe4" - } + }, + "isCustom": false }, { "name": "lime", @@ -46,7 +51,8 @@ "background": "#263238", "primary": "#AABBC3", "accent": "#aeea00" - } + }, + "isCustom": false }, { "name": "white", @@ -54,7 +60,8 @@ "background": "#ffffff", "primary": "#222222", "accent": "#dddddd" - } + }, + "isCustom": false }, { "name": "tron", @@ -62,7 +69,8 @@ "background": "#242B33", "primary": "#EFFBFF", "accent": "#6EE2FF" - } + }, + "isCustom": false }, { "name": "blues", @@ -70,7 +78,8 @@ "background": "#2B2C56", "primary": "#EFF1FC", "accent": "#6677EB" - } + }, + "isCustom": false }, { "name": "passion", @@ -78,7 +87,8 @@ "background": "#f5f5f5", "primary": "#12005e", "accent": "#8e24aa" - } + }, + "isCustom": false }, { "name": "chalk", @@ -86,7 +96,8 @@ "background": "#263238", "primary": "#AABBC3", "accent": "#FF869A" - } + }, + "isCustom": false }, { "name": "paper", @@ -94,7 +105,8 @@ "background": "#F8F6F1", "primary": "#4C432E", "accent": "#AA9A73" - } + }, + "isCustom": false }, { "name": "neon", @@ -102,7 +114,8 @@ "background": "#091833", "primary": "#EFFBFF", "accent": "#ea00d9" - } + }, + "isCustom": false }, { "name": "pumpkin", @@ -110,7 +123,8 @@ "background": "#2d3436", "primary": "#EFFBFF", "accent": "#ffa500" - } + }, + "isCustom": false }, { "name": "onedark", @@ -118,7 +132,17 @@ "background": "#282c34", "primary": "#dfd9d6", "accent": "#98c379" - } + }, + "isCustom": false + }, + { + "name": "mint", + "colors": { + "background": "#282525", + "primary": "#d9d9d9", + "accent": "#50fbc2" + }, + "isCustom": false } ] }