Merge branch 'feature' into v1.7.0

This commit is contained in:
Paweł Malak 2021-10-11 14:31:59 +02:00
commit 55f192f664
53 changed files with 1172 additions and 385 deletions

View file

@ -1,3 +1,9 @@
### v1.7.0 (TBA)
- Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67))
- Fixed bug related to creating new apps/bookmarks with custom icon ([#83](https://github.com/pawelmalak/flame/issues/83))
- URL can now be assigned to notifications. Clicking on "New version is available" popup will now redirect to changelog ([#86](https://github.com/pawelmalak/flame/issues/86))
- Added static fonts ([#94](https://github.com/pawelmalak/flame/issues/94))
### v1.6.9 (2021-10-09) ### v1.6.9 (2021-10-09)
- Added option for remote docker host ([#97](https://github.com/pawelmalak/flame/issues/97)) - Added option for remote docker host ([#97](https://github.com/pawelmalak/flame/issues/97))

1
api.js
View file

@ -20,6 +20,7 @@ api.use('/api/config', require('./routes/config'));
api.use('/api/weather', require('./routes/weather')); api.use('/api/weather', require('./routes/weather'));
api.use('/api/categories', require('./routes/category')); api.use('/api/categories', require('./routes/category'));
api.use('/api/bookmarks', require('./routes/bookmark')); api.use('/api/bookmarks', require('./routes/bookmark'));
api.use('/api/queries', require('./routes/queries'));
// Custom error handler // Custom error handler
api.use(errorHandler); api.use(errorHandler);

View file

@ -4,16 +4,15 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Flame - self-hosted startpage for your server" /> <meta
<link rel="preconnect" href="https://fonts.gstatic.com"> name="description"
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet"> content="Flame - self-hosted startpage for your server"
<link rel="stylesheet" href="%PUBLIC_URL%/flame.css"> />
<link rel="stylesheet" href="%PUBLIC_URL%/flame.css" />
<title>Flame</title> <title>Flame</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

View file

@ -1,5 +1,5 @@
import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { getConfig, setTheme } from './store/actions'; import { fetchQueries, getConfig, setTheme } from './store/actions';
import 'external-svg-loader'; import 'external-svg-loader';
// Redux // Redux
@ -27,15 +27,18 @@ if (localStorage.theme) {
// Check for updates // Check for updates
checkVersion(); checkVersion();
// fetch queries
store.dispatch<any>(fetchQueries());
const App = (): JSX.Element => { const App = (): JSX.Element => {
return ( return (
<Provider store={store}> <Provider store={store}>
<BrowserRouter> <BrowserRouter>
<Switch> <Switch>
<Route exact path='/' component={Home} /> <Route exact path="/" component={Home} />
<Route path='/settings' component={Settings} /> <Route path="/settings" component={Settings} />
<Route path='/applications' component={Apps} /> <Route path="/applications" component={Apps} />
<Route path='/bookmarks' component={Bookmarks} /> <Route path="/bookmarks" component={Bookmarks} />
</Switch> </Switch>
</BrowserRouter> </BrowserRouter>
<NotificationCenter /> <NotificationCenter />

View file

@ -22,7 +22,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
const [formData, setFormData] = useState<NewApp>({ const [formData, setFormData] = useState<NewApp>({
name: '', name: '',
url: '', url: '',
icon: '' icon: '',
}); });
useEffect(() => { useEffect(() => {
@ -30,13 +30,13 @@ const AppForm = (props: ComponentProps): JSX.Element => {
setFormData({ setFormData({
name: props.app.name, name: props.app.name,
url: props.app.url, url: props.app.url,
icon: props.app.icon icon: props.app.icon,
}); });
} else { } else {
setFormData({ setFormData({
name: '', name: '',
url: '', url: '',
icon: '' icon: '',
}); });
} }
}, [props.app]); }, [props.app]);
@ -44,7 +44,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => { const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
}); });
}; };
@ -59,6 +59,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
const createFormData = (): FormData => { const createFormData = (): FormData => {
const data = new FormData(); const data = new FormData();
if (customIcon) { if (customIcon) {
data.append('icon', customIcon); data.append('icon', customIcon);
} }
@ -88,10 +89,8 @@ const AppForm = (props: ComponentProps): JSX.Element => {
setFormData({ setFormData({
name: '', name: '',
url: '', url: '',
icon: '' icon: '',
}); });
setCustomIcon(null);
}; };
return ( return (
@ -100,33 +99,33 @@ const AppForm = (props: ComponentProps): JSX.Element => {
formHandler={formSubmitHandler} formHandler={formSubmitHandler}
> >
<InputGroup> <InputGroup>
<label htmlFor='name'>App Name</label> <label htmlFor="name">App Name</label>
<input <input
type='text' type="text"
name='name' name="name"
id='name' id="name"
placeholder='Bookstack' placeholder="Bookstack"
required required
value={formData.name} value={formData.name}
onChange={e => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor='url'>App URL</label> <label htmlFor="url">App URL</label>
<input <input
type='text' type="text"
name='url' name="url"
id='url' id="url"
placeholder='bookstack.example.com' placeholder="bookstack.example.com"
required required
value={formData.url} value={formData.url}
onChange={e => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
<a <a
href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks' href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target='_blank' target="_blank"
rel='noreferrer' rel="noreferrer"
> >
{' '} {' '}
Check supported URL formats Check supported URL formats
@ -136,19 +135,19 @@ const AppForm = (props: ComponentProps): JSX.Element => {
{!useCustomIcon ? ( {!useCustomIcon ? (
// use mdi icon // use mdi icon
<InputGroup> <InputGroup>
<label htmlFor='icon'>App Icon</label> <label htmlFor="icon">App Icon</label>
<input <input
type='text' type="text"
name='icon' name="icon"
id='icon' id="icon"
placeholder='book-open-outline' placeholder="book-open-outline"
required required
value={formData.icon} value={formData.icon}
onChange={e => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
Use icon name from MDI. Use icon name from MDI.
<a href='https://materialdesignicons.com/' target='blank'> <a href="https://materialdesignicons.com/" target="blank">
{' '} {' '}
Click here for reference Click here for reference
</a> </a>
@ -163,17 +162,20 @@ const AppForm = (props: ComponentProps): JSX.Element => {
) : ( ) : (
// upload custom icon // upload custom icon
<InputGroup> <InputGroup>
<label htmlFor='icon'>App Icon</label> <label htmlFor="icon">App Icon</label>
<input <input
type='file' type="file"
name='icon' name="icon"
id='icon' id="icon"
required required
onChange={e => fileChangeHandler(e)} onChange={(e) => fileChangeHandler(e)}
accept='.jpg,.jpeg,.png,.svg' accept=".jpg,.jpeg,.png,.svg"
/> />
<span <span
onClick={() => toggleUseCustomIcon(!useCustomIcon)} onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch} className={classes.Switch}
> >
Switch to MDI Switch to MDI

View file

@ -1,32 +1,40 @@
// React
import { import {
useState, useState,
SyntheticEvent, SyntheticEvent,
Fragment, Fragment,
ChangeEvent, ChangeEvent,
useEffect useEffect,
} from 'react'; } from 'react';
import { connect } from 'react-redux';
import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; // Redux
import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import { connect } from 'react-redux';
import {
Bookmark,
Category,
GlobalState,
NewBookmark,
NewCategory,
NewNotification
} from '../../../interfaces';
import { ContentType } from '../Bookmarks';
import { import {
getCategories, getCategories,
addCategory, addCategory,
addBookmark, addBookmark,
updateCategory, updateCategory,
updateBookmark, updateBookmark,
createNotification createNotification,
} from '../../../store/actions'; } from '../../../store/actions';
// Typescript
import {
Bookmark,
Category,
GlobalState,
NewBookmark,
NewCategory,
NewNotification,
} from '../../../interfaces';
import { ContentType } from '../Bookmarks';
// UI
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button'; import Button from '../../UI/Buttons/Button/Button';
// CSS
import classes from './BookmarkForm.module.css'; import classes from './BookmarkForm.module.css';
interface ComponentProps { interface ComponentProps {
@ -53,14 +61,14 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false); const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null); const [customIcon, setCustomIcon] = useState<File | null>(null);
const [categoryName, setCategoryName] = useState<NewCategory>({ const [categoryName, setCategoryName] = useState<NewCategory>({
name: '' name: '',
}); });
const [formData, setFormData] = useState<NewBookmark>({ const [formData, setFormData] = useState<NewBookmark>({
name: '', name: '',
url: '', url: '',
categoryId: -1, categoryId: -1,
icon: '' icon: '',
}); });
// Load category data if provided for editing // Load category data if provided for editing
@ -79,14 +87,14 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
name: props.bookmark.name, name: props.bookmark.name,
url: props.bookmark.url, url: props.bookmark.url,
categoryId: props.bookmark.categoryId, categoryId: props.bookmark.categoryId,
icon: props.bookmark.icon icon: props.bookmark.icon,
}); });
} else { } else {
setFormData({ setFormData({
name: '', name: '',
url: '', url: '',
categoryId: -1, categoryId: -1,
icon: '' icon: '',
}); });
} }
}, [props.bookmark]); }, [props.bookmark]);
@ -117,7 +125,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
if (formData.categoryId === -1) { if (formData.categoryId === -1) {
props.createNotification({ props.createNotification({
title: 'Error', title: 'Error',
message: 'Please select category' message: 'Please select category',
}); });
return; return;
} }
@ -133,10 +141,10 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
name: '', name: '',
url: '', url: '',
categoryId: formData.categoryId, categoryId: formData.categoryId,
icon: '' icon: '',
}); });
setCustomIcon(null); // setCustomIcon(null);
} }
} else { } else {
// Update // Update
@ -150,12 +158,12 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
const data = createFormData(); const data = createFormData();
props.updateBookmark(props.bookmark.id, data, { props.updateBookmark(props.bookmark.id, data, {
prev: props.bookmark.categoryId, prev: props.bookmark.categoryId,
curr: formData.categoryId curr: formData.categoryId,
}); });
} else { } else {
props.updateBookmark(props.bookmark.id, formData, { props.updateBookmark(props.bookmark.id, formData, {
prev: props.bookmark.categoryId, prev: props.bookmark.categoryId,
curr: formData.categoryId curr: formData.categoryId,
}); });
} }
@ -163,7 +171,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
name: '', name: '',
url: '', url: '',
categoryId: -1, categoryId: -1,
icon: '' icon: '',
}); });
setCustomIcon(null); setCustomIcon(null);
@ -176,14 +184,14 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => { const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({ setFormData({
...formData, ...formData,
[e.target.name]: e.target.value [e.target.name]: e.target.value,
}); });
}; };
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => { const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
setFormData({ setFormData({
...formData, ...formData,
categoryId: parseInt(e.target.value) categoryId: parseInt(e.target.value),
}); });
}; };
@ -215,48 +223,48 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
{props.contentType === ContentType.category ? ( {props.contentType === ContentType.category ? (
<Fragment> <Fragment>
<InputGroup> <InputGroup>
<label htmlFor='categoryName'>Category Name</label> <label htmlFor="categoryName">Category Name</label>
<input <input
type='text' type="text"
name='categoryName' name="categoryName"
id='categoryName' id="categoryName"
placeholder='Social Media' placeholder="Social Media"
required required
value={categoryName.name} value={categoryName.name}
onChange={e => setCategoryName({ name: e.target.value })} onChange={(e) => setCategoryName({ name: e.target.value })}
/> />
</InputGroup> </InputGroup>
</Fragment> </Fragment>
) : ( ) : (
<Fragment> <Fragment>
<InputGroup> <InputGroup>
<label htmlFor='name'>Bookmark Name</label> <label htmlFor="name">Bookmark Name</label>
<input <input
type='text' type="text"
name='name' name="name"
id='name' id="name"
placeholder='Reddit' placeholder="Reddit"
required required
value={formData.name} value={formData.name}
onChange={e => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor='url'>Bookmark URL</label> <label htmlFor="url">Bookmark URL</label>
<input <input
type='text' type="text"
name='url' name="url"
id='url' id="url"
placeholder='reddit.com' placeholder="reddit.com"
required required
value={formData.url} value={formData.url}
onChange={e => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
<a <a
href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks' href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target='_blank' target="_blank"
rel='noreferrer' rel="noreferrer"
> >
{' '} {' '}
Check supported URL formats Check supported URL formats
@ -264,12 +272,12 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
</span> </span>
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor='categoryId'>Bookmark Category</label> <label htmlFor="categoryId">Bookmark Category</label>
<select <select
name='categoryId' name="categoryId"
id='categoryId' id="categoryId"
required required
onChange={e => selectChangeHandler(e)} onChange={(e) => selectChangeHandler(e)}
value={formData.categoryId} value={formData.categoryId}
> >
<option value={-1}>Select category</option> <option value={-1}>Select category</option>
@ -285,18 +293,18 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
{!useCustomIcon ? ( {!useCustomIcon ? (
// mdi // mdi
<InputGroup> <InputGroup>
<label htmlFor='icon'>Bookmark Icon (optional)</label> <label htmlFor="icon">Bookmark Icon (optional)</label>
<input <input
type='text' type="text"
name='icon' name="icon"
id='icon' id="icon"
placeholder='book-open-outline' placeholder="book-open-outline"
value={formData.icon} value={formData.icon}
onChange={e => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
Use icon name from MDI. Use icon name from MDI.
<a href='https://materialdesignicons.com/' target='blank'> <a href="https://materialdesignicons.com/" target="blank">
{' '} {' '}
Click here for reference Click here for reference
</a> </a>
@ -311,16 +319,19 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
) : ( ) : (
// custom // custom
<InputGroup> <InputGroup>
<label htmlFor='icon'>Bookmark Icon (optional)</label> <label htmlFor="icon">Bookmark Icon (optional)</label>
<input <input
type='file' type="file"
name='icon' name="icon"
id='icon' id="icon"
onChange={e => fileChangeHandler(e)} onChange={(e) => fileChangeHandler(e)}
accept='.jpg,.jpeg,.png,.svg' accept=".jpg,.jpeg,.png,.svg"
/> />
<span <span
onClick={() => toggleUseCustomIcon(!useCustomIcon)} onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch} className={classes.Switch}
> >
Switch to MDI Switch to MDI
@ -336,7 +347,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
const mapStateToProps = (state: GlobalState) => { const mapStateToProps = (state: GlobalState) => {
return { return {
categories: state.bookmark.categories categories: state.bookmark.categories,
}; };
}; };
@ -346,7 +357,7 @@ const dispatchMap = {
addBookmark, addBookmark,
updateCategory, updateCategory,
updateBookmark, updateBookmark,
createNotification createNotification,
}; };
export default connect(mapStateToProps, dispatchMap)(BookmarkForm); export default connect(mapStateToProps, dispatchMap)(BookmarkForm);

View file

@ -20,19 +20,20 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
<Notification <Notification
title={notification.title} title={notification.title}
message={notification.message} message={notification.message}
url={notification.url || null}
id={notification.id} id={notification.id}
key={notification.id} key={notification.id}
/> />
) );
})} })}
</div> </div>
) );
} };
const mapStateToProps = (state: GlobalState) => { const mapStateToProps = (state: GlobalState) => {
return { return {
notifications: state.notification.notifications notifications: state.notification.notifications,
} };
} };
export default connect(mapStateToProps)(NotificationCenter); export default connect(mapStateToProps)(NotificationCenter);

View file

@ -11,7 +11,7 @@ import { NewNotification } from '../../interfaces';
import classes from './SearchBar.module.css'; import classes from './SearchBar.module.css';
// Utils // Utils
import { searchParser } from '../../utility'; import { searchParser, urlParser, redirectUrl } from '../../utility';
interface ComponentProps { interface ComponentProps {
createNotification: (notification: NewNotification) => void; createNotification: (notification: NewNotification) => void;
@ -28,28 +28,28 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
}, []); }, []);
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => { const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
const searchResult = searchParser(inputRef.current.value); const { isLocal, search, query, isURL, sameTab } = searchParser(
inputRef.current.value
);
if (searchResult.isLocal) { if (isLocal) {
setLocalSearch(searchResult.search); setLocalSearch(search);
} }
if (e.code === 'Enter') { if (e.code === 'Enter') {
if (!searchResult.query.prefix) { if (!query.prefix) {
createNotification({ createNotification({
title: 'Error', title: 'Error',
message: 'Prefix not found', message: 'Prefix not found',
}); });
} else if (searchResult.isLocal) { } else if (isURL) {
setLocalSearch(searchResult.search); const url = urlParser(inputRef.current.value)[1];
redirectUrl(url, sameTab);
} else if (isLocal) {
setLocalSearch(search);
} else { } else {
if (searchResult.sameTab) { const url = `${query.template}${search}`;
document.location.replace( redirectUrl(url, sameTab);
`${searchResult.query.template}${searchResult.search}`
);
} else {
window.open(`${searchResult.query.template}${searchResult.search}`);
}
} }
} }
}; };

View file

@ -13,20 +13,16 @@ import {
import { import {
GlobalState, GlobalState,
NewNotification, NewNotification,
Query,
SettingsForm, SettingsForm,
} from '../../../interfaces'; } from '../../../interfaces';
// UI // UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button'; import Button from '../../UI/Buttons/Button/Button';
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
// CSS
import classes from './OtherSettings.module.css';
// Utils // Utils
import { searchConfig } from '../../../utility'; import { searchConfig } from '../../../utility';
import { queries } from '../../../utility/searchQueries.json';
interface ComponentProps { interface ComponentProps {
createNotification: (notification: NewNotification) => void; createNotification: (notification: NewNotification) => void;
@ -45,12 +41,9 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
hideHeader: 0, hideHeader: 0,
hideApps: 0, hideApps: 0,
hideCategories: 0, hideCategories: 0,
hideSearch: 0,
defaultSearchProvider: 'd',
useOrdering: 'createdAt', useOrdering: 'createdAt',
appsSameTab: 0, appsSameTab: 0,
bookmarksSameTab: 0, bookmarksSameTab: 0,
searchSameTab: 0,
dockerApps: 1, dockerApps: 1,
dockerHost: 'localhost', dockerHost: 'localhost',
kubernetesApps: 1, kubernetesApps: 1,
@ -66,12 +59,9 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
hideHeader: searchConfig('hideHeader', 0), hideHeader: searchConfig('hideHeader', 0),
hideApps: searchConfig('hideApps', 0), hideApps: searchConfig('hideApps', 0),
hideCategories: searchConfig('hideCategories', 0), hideCategories: searchConfig('hideCategories', 0),
hideSearch: searchConfig('hideSearch', 0),
defaultSearchProvider: searchConfig('defaultSearchProvider', 'd'),
useOrdering: searchConfig('useOrdering', 'createdAt'), useOrdering: searchConfig('useOrdering', 'createdAt'),
appsSameTab: searchConfig('appsSameTab', 0), appsSameTab: searchConfig('appsSameTab', 0),
bookmarksSameTab: searchConfig('bookmarksSameTab', 0), bookmarksSameTab: searchConfig('bookmarksSameTab', 0),
searchSameTab: searchConfig('searchSameTab', 0),
dockerApps: searchConfig('dockerApps', 0), dockerApps: searchConfig('dockerApps', 0),
dockerHost: searchConfig('dockerHost', 'localhost'), dockerHost: searchConfig('dockerHost', 'localhost'),
kubernetesApps: searchConfig('kubernetesApps', 0), kubernetesApps: searchConfig('kubernetesApps', 0),
@ -114,7 +104,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
return ( return (
<form onSubmit={(e) => formSubmitHandler(e)}> <form onSubmit={(e) => formSubmitHandler(e)}>
{/* OTHER OPTIONS */} {/* OTHER OPTIONS */}
<h2 className={classes.SettingsSection}>Miscellaneous</h2> <SettingsHeadline text="Miscellaneous" />
<InputGroup> <InputGroup>
<label htmlFor="customTitle">Custom page title</label> <label htmlFor="customTitle">Custom page title</label>
<input <input
@ -128,7 +118,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
</InputGroup> </InputGroup>
{/* BEAHVIOR OPTIONS */} {/* BEAHVIOR OPTIONS */}
<h2 className={classes.SettingsSection}>App Behavior</h2> <SettingsHeadline text="App Behavior" />
<InputGroup> <InputGroup>
<label htmlFor="pinAppsByDefault"> <label htmlFor="pinAppsByDefault">
Pin new applications by default Pin new applications by default
@ -170,35 +160,6 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
<option value="orderId">Custom order</option> <option value="orderId">Custom order</option>
</select> </select>
</InputGroup> </InputGroup>
<InputGroup>
<label htmlFor="defaultSearchProvider">Default Search Provider</label>
<select
id="defaultSearchProvider"
name="defaultSearchProvider"
value={formData.defaultSearchProvider}
onChange={(e) => inputChangeHandler(e)}
>
{queries.map((query: Query, idx) => (
<option key={idx} value={query.prefix}>
{query.name}
</option>
))}
</select>
</InputGroup>
<InputGroup>
<label htmlFor="searchSameTab">
Open search results in the same tab
</label>
<select
id="searchSameTab"
name="searchSameTab"
value={formData.searchSameTab}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="appsSameTab">Open applications in the same tab</label> <label htmlFor="appsSameTab">Open applications in the same tab</label>
<select <select
@ -225,19 +186,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
</InputGroup> </InputGroup>
{/* MODULES OPTIONS */} {/* MODULES OPTIONS */}
<h2 className={classes.SettingsSection}>Modules</h2> <SettingsHeadline text="Modules" />
<InputGroup>
<label htmlFor="hideSearch">Hide search bar</label>
<select
id="hideSearch"
name="hideSearch"
value={formData.hideSearch}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="hideHeader">Hide greeting and date</label> <label htmlFor="hideHeader">Hide greeting and date</label>
<select <select
@ -276,7 +225,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
</InputGroup> </InputGroup>
{/* DOCKER SETTINGS */} {/* DOCKER SETTINGS */}
<h2 className={classes.SettingsSection}>Docker</h2> <SettingsHeadline text="Docker" />
<InputGroup> <InputGroup>
<label htmlFor="dockerHost">Docker Host</label> <label htmlFor="dockerHost">Docker Host</label>
<input <input
@ -316,7 +265,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
</InputGroup> </InputGroup>
{/* KUBERNETES SETTINGS */} {/* KUBERNETES SETTINGS */}
<h2 className={classes.SettingsSection}>Kubernetes</h2> <SettingsHeadline text="Kubernetes" />
<InputGroup> <InputGroup>
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label> <label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
<select <select

View file

@ -0,0 +1,30 @@
.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;
}

View file

@ -0,0 +1,112 @@
import { Fragment, useState } from 'react';
import { connect } from 'react-redux';
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 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;
}
const CustomQueries = (props: Props): JSX.Element => {
const { customQueries, deleteQuery, createNotification } = props;
const [modalIsOpen, setModalIsOpen] = useState(false);
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
const updateHandler = (query: Query) => {
setEditableQuery(query);
setModalIsOpen(true);
};
const deleteHandler = (query: Query) => {
const currentProvider = searchConfig('defaultSearchProvider', 'l');
const isCurrent = currentProvider === query.prefix;
if (isCurrent) {
createNotification({
title: 'Error',
message: 'Cannot delete active provider',
});
} else if (
window.confirm(`Are you sure you want to delete this provider?`)
) {
deleteQuery(query.prefix);
}
};
return (
<Fragment>
<Modal
isOpen={modalIsOpen}
setIsOpen={() => setModalIsOpen(!modalIsOpen)}
>
{editableQuery ? (
<QueriesForm
modalHandler={() => setModalIsOpen(!modalIsOpen)}
query={editableQuery}
/>
) : (
<QueriesForm modalHandler={() => setModalIsOpen(!modalIsOpen)} />
)}
</Modal>
<div>
<div className={classes.QueriesGrid}>
{customQueries.length > 0 && (
<Fragment>
<span>Name</span>
<span>Prefix</span>
<span>Actions</span>
<div className={classes.Separator}></div>
</Fragment>
)}
{customQueries.map((q: Query, idx) => (
<Fragment key={idx}>
<span>{q.name}</span>
<span>{q.prefix}</span>
<span className={classes.ActionIcons}>
<span onClick={() => updateHandler(q)}>
<Icon icon="mdiPencil" />
</span>
<span onClick={() => deleteHandler(q)}>
<Icon icon="mdiDelete" />
</span>
</span>
</Fragment>
))}
</div>
<Button
click={() => {
setEditableQuery(null);
setModalIsOpen(true);
}}
>
Add new search provider
</Button>
</div>
</Fragment>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
customQueries: state.config.customQueries,
};
};
export default connect(mapStateToProps, { deleteQuery, createNotification })(
CustomQueries
);

View file

@ -0,0 +1,109 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
import { Query } from '../../../../interfaces';
import Button from '../../../UI/Buttons/Button/Button';
import InputGroup from '../../../UI/Forms/InputGroup/InputGroup';
import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
import { connect } from 'react-redux';
import { addQuery, updateQuery } from '../../../../store/actions';
interface Props {
modalHandler: () => void;
addQuery: (query: Query) => {};
updateQuery: (query: Query, Oldprefix: string) => {};
query?: Query;
}
const QueriesForm = (props: Props): JSX.Element => {
const { modalHandler, addQuery, updateQuery, query } = props;
const [formData, setFormData] = useState<Query>({
name: '',
prefix: '',
template: '',
});
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
};
const formHandler = (e: FormEvent) => {
e.preventDefault();
if (query) {
updateQuery(formData, query.prefix);
} else {
addQuery(formData);
}
// close modal
modalHandler();
// clear form
setFormData({
name: '',
prefix: '',
template: '',
});
};
useEffect(() => {
if (query) {
setFormData(query);
} else {
setFormData({
name: '',
prefix: '',
template: '',
});
}
}, [query]);
return (
<ModalForm modalHandler={modalHandler} formHandler={formHandler}>
<InputGroup>
<label htmlFor="name">Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Google"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="name">Prefix</label>
<input
type="text"
name="prefix"
id="prefix"
placeholder="g"
required
value={formData.prefix}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="name">Query Template</label>
<input
type="text"
name="template"
id="template"
placeholder="https://www.google.com/search?q="
required
value={formData.template}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
</ModalForm>
);
};
export default connect(null, { addQuery, updateQuery })(QueriesForm);

View file

@ -0,0 +1,154 @@
// React
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
import { connect } from 'react-redux';
// State
import { createNotification, updateConfig } from '../../../store/actions';
// Typescript
import {
GlobalState,
NewNotification,
Query,
SearchForm,
} from '../../../interfaces';
// Components
import CustomQueries from './CustomQueries/CustomQueries';
// UI
import Button from '../../UI/Buttons/Button/Button';
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
// Utils
import { searchConfig } from '../../../utility';
// Data
import { queries } from '../../../utility/searchQueries.json';
interface Props {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: SearchForm) => void;
loading: boolean;
customQueries: Query[];
}
const SearchSettings = (props: Props): JSX.Element => {
// Initial state
const [formData, setFormData] = useState<SearchForm>({
hideSearch: 0,
defaultSearchProvider: 'l',
searchSameTab: 0,
});
// Get config
useEffect(() => {
setFormData({
hideSearch: searchConfig('hideSearch', 0),
defaultSearchProvider: searchConfig('defaultSearchProvider', 'd'),
searchSameTab: searchConfig('searchSameTab', 0),
});
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await props.updateConfig(formData);
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
isNumber?: boolean
) => {
let value: string | number = e.target.value;
if (isNumber) {
value = parseFloat(value);
}
setFormData({
...formData,
[e.target.name]: value,
});
};
return (
<Fragment>
{/* GENERAL SETTINGS */}
<form
onSubmit={(e) => formSubmitHandler(e)}
style={{ marginBottom: '30px' }}
>
<SettingsHeadline text="General" />
<InputGroup>
<label htmlFor="defaultSearchProvider">Default Search Provider</label>
<select
id="defaultSearchProvider"
name="defaultSearchProvider"
value={formData.defaultSearchProvider}
onChange={(e) => inputChangeHandler(e)}
>
{[...queries, ...props.customQueries].map((query: Query, idx) => {
const isCustom = idx >= queries.length;
return (
<option key={idx} value={query.prefix}>
{isCustom && '+'} {query.name}
</option>
);
})}
</select>
</InputGroup>
<InputGroup>
<label htmlFor="searchSameTab">
Open search results in the same tab
</label>
<select
id="searchSameTab"
name="searchSameTab"
value={formData.searchSameTab}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor="hideSearch">Hide search bar</label>
<select
id="hideSearch"
name="hideSearch"
value={formData.hideSearch}
onChange={(e) => inputChangeHandler(e, true)}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
{/* CUSTOM QUERIES */}
<SettingsHeadline text="Custom search providers" />
<CustomQueries />
</Fragment>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading,
customQueries: state.config.customQueries,
};
};
const actions = {
createNotification,
updateConfig,
};
export default connect(mapStateToProps, actions)(SearchSettings);

View file

@ -1,73 +1,61 @@
//
import { NavLink, Link, Switch, Route } from 'react-router-dom'; import { NavLink, Link, Switch, Route } from 'react-router-dom';
// Typescript
import { Route as SettingsRoute } from '../../interfaces';
// CSS
import classes from './Settings.module.css'; import classes from './Settings.module.css';
import { Container } from '../UI/Layout/Layout'; // Components
import Headline from '../UI/Headlines/Headline/Headline';
import Themer from '../Themer/Themer'; import Themer from '../Themer/Themer';
import WeatherSettings from './WeatherSettings/WeatherSettings'; import WeatherSettings from './WeatherSettings/WeatherSettings';
import OtherSettings from './OtherSettings/OtherSettings'; import OtherSettings from './OtherSettings/OtherSettings';
import AppDetails from './AppDetails/AppDetails'; import AppDetails from './AppDetails/AppDetails';
import StyleSettings from './StyleSettings/StyleSettings'; import StyleSettings from './StyleSettings/StyleSettings';
import SearchSettings from './SearchSettings/SearchSettings';
// UI
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
// Data
import { routes } from './settings.json';
const Settings = (): JSX.Element => { const Settings = (): JSX.Element => {
return ( return (
<Container> <Container>
<Headline <Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
title='Settings'
subtitle={<Link to='/'>Go back</Link>}
/>
<div className={classes.Settings}> <div className={classes.Settings}>
{/* NAVIGATION MENU */}
<nav className={classes.SettingsNav}> <nav className={classes.SettingsNav}>
<NavLink {routes.map(({ name, dest }: SettingsRoute, idx) => (
className={classes.SettingsNavLink} <NavLink
activeClassName={classes.SettingsNavLinkActive} className={classes.SettingsNavLink}
exact activeClassName={classes.SettingsNavLinkActive}
to='/settings'> exact
Theme to={dest}
</NavLink> key={idx}
<NavLink >
className={classes.SettingsNavLink} {name}
activeClassName={classes.SettingsNavLinkActive} </NavLink>
exact ))}
to='/settings/weather'>
Weather
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/other'>
Other
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/css'>
CSS
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/app'>
App
</NavLink>
</nav> </nav>
{/* ROUTES */}
<section className={classes.SettingsContent}> <section className={classes.SettingsContent}>
<Switch> <Switch>
<Route exact path='/settings' component={Themer} /> <Route exact path="/settings" component={Themer} />
<Route path='/settings/weather' component={WeatherSettings} /> <Route path="/settings/weather" component={WeatherSettings} />
<Route path='/settings/other' component={OtherSettings} /> <Route path="/settings/search" component={SearchSettings} />
<Route path='/settings/css' component={StyleSettings} /> <Route path="/settings/other" component={OtherSettings} />
<Route path='/settings/app' component={AppDetails} /> <Route path="/settings/css" component={StyleSettings} />
<Route path="/settings/app" component={AppDetails} />
</Switch> </Switch>
</section> </section>
</div> </div>
</Container> </Container>
) );
} };
export default Settings; export default Settings;

View file

@ -0,0 +1,28 @@
{
"routes": [
{
"name": "Theme",
"dest": "/settings"
},
{
"name": "Weather",
"dest": "/settings/weather"
},
{
"name": "Search",
"dest": "/settings/search"
},
{
"name": "Other",
"dest": "/settings/other"
},
{
"name": "CSS",
"dest": "/settings/css"
},
{
"name": "App",
"dest": "/settings/app"
}
]
}

View file

@ -1,4 +1,4 @@
.SettingsSection { .SettingsHeadline {
color: var(--color-primary); color: var(--color-primary);
padding-bottom: 3px; padding-bottom: 3px;
margin-bottom: 10px; margin-bottom: 10px;
@ -6,4 +6,4 @@
font-weight: 500; font-weight: 500;
border-bottom: 2px solid var(--color-accent); border-bottom: 2px solid var(--color-accent);
display: inline-block; display: inline-block;
} }

View file

@ -0,0 +1,11 @@
const classes = require('./SettingsHeadline.module.css');
interface Props {
text: string;
}
const SettingsHeadline = (props: Props): JSX.Element => {
return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
};
export default SettingsHeadline;

View file

@ -8,12 +8,16 @@ interface ComponentProps {
title: string; title: string;
message: string; message: string;
id: number; id: number;
url: string | null;
clearNotification: (id: number) => void; clearNotification: (id: number) => void;
} }
const Notification = (props: ComponentProps): JSX.Element => { const Notification = (props: ComponentProps): JSX.Element => {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const elementClasses = [classes.Notification, isOpen ? classes.NotificationOpen : classes.NotificationClose].join(' '); const elementClasses = [
classes.Notification,
isOpen ? classes.NotificationOpen : classes.NotificationClose,
].join(' ');
useEffect(() => { useEffect(() => {
const closeNotification = setTimeout(() => { const closeNotification = setTimeout(() => {
@ -22,21 +26,27 @@ const Notification = (props: ComponentProps): JSX.Element => {
const clearNotification = setTimeout(() => { const clearNotification = setTimeout(() => {
props.clearNotification(props.id); props.clearNotification(props.id);
}, 3600) }, 3600);
return () => { return () => {
window.clearTimeout(closeNotification); window.clearTimeout(closeNotification);
window.clearTimeout(clearNotification); window.clearTimeout(clearNotification);
};
}, []);
const clickHandler = () => {
if (props.url) {
window.open(props.url, '_blank');
} }
}, []) };
return ( return (
<div className={elementClasses}> <div className={elementClasses} onClick={clickHandler}>
<h4>{props.title}</h4> <h4>{props.title}</h4>
<p>{props.message}</p> <p>{props.message}</p>
<div className={classes.Pog}></div> <div className={classes.Pog}></div>
</div> </div>
) );
} };
export default connect(null, { clearNotification })(Notification); export default connect(null, { clearNotification })(Notification);

View file

@ -1,3 +1,39 @@
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: local(''),
url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff2') format('woff2'),
url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff') format('woff');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src: local(''),
url('./assets/fonts/Roboto/roboto-v29-latin-500.woff2') format('woff2'),
url('./assets/fonts/Roboto/roboto-v29-latin-500.woff') format('woff');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
src: local(''),
url('./assets/fonts/Roboto/roboto-v29-latin-900.woff2') format('woff2'),
url('./assets/fonts/Roboto/roboto-v29-latin-900.woff') format('woff');
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
src: local(''),
url('./assets/fonts/Roboto/roboto-v29-latin-700.woff2') format('woff2'),
url('./assets/fonts/Roboto/roboto-v29-latin-700.woff') format('woff');
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -5,18 +41,18 @@
} }
body { body {
--color-background: #2B2C56; --color-background: #242b33;
--color-primary: #EFF1FC; --color-primary: #effbff;
--color-accent: #6677EB; --color-accent: #6ee2ff;
--spacing-ui: 10px; --spacing-ui: 10px;
background-color: var(--color-background); background-color: var(--color-background);
transition: background-color 0.3s; transition: background-color 0.3s;
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Roboto, sans-serif; font-family: Roboto, sans-serif;
font-size: 14px; font-size: 14px;
} }
a { a {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }

View file

@ -5,6 +5,12 @@ export interface WeatherForm {
isCelsius: number; isCelsius: number;
} }
export interface SearchForm {
hideSearch: number;
defaultSearchProvider: string;
searchSameTab: number;
}
export interface SettingsForm { export interface SettingsForm {
customTitle: string; customTitle: string;
pinAppsByDefault: number; pinAppsByDefault: number;
@ -12,12 +18,12 @@ export interface SettingsForm {
hideHeader: number; hideHeader: number;
hideApps: number; hideApps: number;
hideCategories: number; hideCategories: number;
hideSearch: number; // hideSearch: number;
defaultSearchProvider: string; // defaultSearchProvider: string;
useOrdering: string; useOrdering: string;
appsSameTab: number; appsSameTab: number;
bookmarksSameTab: number; bookmarksSameTab: number;
searchSameTab: number; // searchSameTab: number;
dockerApps: number; dockerApps: number;
dockerHost: string; dockerHost: string;
kubernetesApps: number; kubernetesApps: number;

View file

@ -1,8 +1,9 @@
export interface NewNotification { export interface NewNotification {
title: string; title: string;
message: string; message: string;
url?: string;
} }
export interface Notification extends NewNotification { export interface Notification extends NewNotification {
id: number; id: number;
} }

View file

@ -0,0 +1,4 @@
export interface Route {
name: string;
dest: string;
}

View file

@ -2,6 +2,7 @@ import { Query } from './Query';
export interface SearchResult { export interface SearchResult {
isLocal: boolean; isLocal: boolean;
isURL: boolean;
sameTab: boolean; sameTab: boolean;
search: string; search: string;
query: Query; query: Query;

View file

@ -10,3 +10,4 @@ export * from './Config';
export * from './Forms'; export * from './Forms';
export * from './Query'; export * from './Query';
export * from './SearchResult'; export * from './SearchResult';
export * from './Route';

View file

@ -26,8 +26,14 @@ import {
ClearNotificationAction, ClearNotificationAction,
// Config // Config
GetConfigAction, GetConfigAction,
UpdateConfigAction UpdateConfigAction,
} from './'; } from './';
import {
AddQueryAction,
DeleteQueryAction,
FetchQueriesAction,
UpdateQueryAction,
} from './config';
export enum ActionTypes { export enum ActionTypes {
// Theme // Theme
@ -62,35 +68,43 @@ export enum ActionTypes {
clearNotification = 'CLEAR_NOTIFICATION', clearNotification = 'CLEAR_NOTIFICATION',
// Config // Config
getConfig = 'GET_CONFIG', getConfig = 'GET_CONFIG',
updateConfig = 'UPDATE_CONFIG' updateConfig = 'UPDATE_CONFIG',
fetchQueries = 'FETCH_QUERIES',
addQuery = 'ADD_QUERY',
deleteQuery = 'DELETE_QUERY',
updateQuery = 'UPDATE_QUERY',
} }
export type Action = export type Action =
// Theme // Theme
SetThemeAction | | SetThemeAction
// Apps // Apps
GetAppsAction<any> | | GetAppsAction<any>
PinAppAction | | PinAppAction
AddAppAction | | AddAppAction
DeleteAppAction | | DeleteAppAction
UpdateAppAction | | UpdateAppAction
ReorderAppsAction | | ReorderAppsAction
SortAppsAction | | SortAppsAction
// Categories // Categories
GetCategoriesAction<any> | | GetCategoriesAction<any>
AddCategoryAction | | AddCategoryAction
PinCategoryAction | | PinCategoryAction
DeleteCategoryAction | | DeleteCategoryAction
UpdateCategoryAction | | UpdateCategoryAction
SortCategoriesAction | | SortCategoriesAction
ReorderCategoriesAction | | ReorderCategoriesAction
// Bookmarks // Bookmarks
AddBookmarkAction | | AddBookmarkAction
DeleteBookmarkAction | | DeleteBookmarkAction
UpdateBookmarkAction | | UpdateBookmarkAction
// Notifications // Notifications
CreateNotificationAction | | CreateNotificationAction
ClearNotificationAction | | ClearNotificationAction
// Config // Config
GetConfigAction | | GetConfigAction
UpdateConfigAction; | UpdateConfigAction
| FetchQueriesAction
| AddQueryAction
| DeleteQueryAction
| UpdateQueryAction;

View file

@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import { ActionTypes } from './actionTypes'; import { ActionTypes } from './actionTypes';
import { Config, ApiResponse } from '../../interfaces'; import { Config, ApiResponse, Query } from '../../interfaces';
import { CreateNotificationAction } from './notification'; import { CreateNotificationAction } from './notification';
import { searchConfig } from '../../utility'; import { searchConfig } from '../../utility';
@ -13,18 +13,18 @@ export interface GetConfigAction {
export const getConfig = () => async (dispatch: Dispatch) => { export const getConfig = () => async (dispatch: Dispatch) => {
try { try {
const res = await axios.get<ApiResponse<Config[]>>('/api/config'); const res = await axios.get<ApiResponse<Config[]>>('/api/config');
dispatch<GetConfigAction>({ dispatch<GetConfigAction>({
type: ActionTypes.getConfig, type: ActionTypes.getConfig,
payload: res.data.data payload: res.data.data,
}) });
// Set custom page title if set // Set custom page title if set
document.title = searchConfig('customTitle', 'Flame'); document.title = searchConfig('customTitle', 'Flame');
} catch (err) { } catch (err) {
console.log(err) console.log(err);
} }
} };
export interface UpdateConfigAction { export interface UpdateConfigAction {
type: ActionTypes.updateConfig; type: ActionTypes.updateConfig;
@ -34,19 +34,102 @@ export interface UpdateConfigAction {
export const updateConfig = (formData: any) => async (dispatch: Dispatch) => { export const updateConfig = (formData: any) => async (dispatch: Dispatch) => {
try { try {
const res = await axios.put<ApiResponse<Config[]>>('/api/config', formData); const res = await axios.put<ApiResponse<Config[]>>('/api/config', formData);
dispatch<CreateNotificationAction>({ dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification, type: ActionTypes.createNotification,
payload: { payload: {
title: 'Success', title: 'Success',
message: 'Settings updated' message: 'Settings updated',
} },
}) });
dispatch<UpdateConfigAction>({ dispatch<UpdateConfigAction>({
type: ActionTypes.updateConfig, type: ActionTypes.updateConfig,
payload: res.data.data payload: res.data.data,
}) });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }
} };
export interface FetchQueriesAction {
type: ActionTypes.fetchQueries;
payload: Query[];
}
export const fetchQueries =
() => async (dispatch: Dispatch<FetchQueriesAction>) => {
try {
const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
dispatch<FetchQueriesAction>({
type: ActionTypes.fetchQueries,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export interface AddQueryAction {
type: ActionTypes.addQuery;
payload: Query;
}
export const addQuery =
(query: Query) => async (dispatch: Dispatch<AddQueryAction>) => {
try {
const res = await axios.post<ApiResponse<Query>>('/api/queries', query);
dispatch<AddQueryAction>({
type: ActionTypes.addQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export interface DeleteQueryAction {
type: ActionTypes.deleteQuery;
payload: Query[];
}
export const deleteQuery =
(prefix: string) => async (dispatch: Dispatch<DeleteQueryAction>) => {
try {
const res = await axios.delete<ApiResponse<Query[]>>(
`/api/queries/${prefix}`
);
dispatch<DeleteQueryAction>({
type: ActionTypes.deleteQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export interface UpdateQueryAction {
type: ActionTypes.updateQuery;
payload: Query[];
}
export const updateQuery =
(query: Query, oldPrefix: string) =>
async (dispatch: Dispatch<UpdateQueryAction>) => {
try {
const res = await axios.put<ApiResponse<Query[]>>(
`/api/queries/${oldPrefix}`,
query
);
dispatch<UpdateQueryAction>({
type: ActionTypes.updateQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};

View file

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

View file

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

View file

@ -3,4 +3,5 @@ export * from './urlParser';
export * from './searchConfig'; export * from './searchConfig';
export * from './checkVersion'; export * from './checkVersion';
export * from './sortData'; export * from './sortData';
export * from './searchParser'; export * from './searchParser';
export * from './redirectUrl';

View file

@ -0,0 +1,7 @@
export const redirectUrl = (url: string, sameTab: boolean) => {
if (sameTab) {
document.location.replace(url);
} else {
window.open(url);
}
};

View file

@ -1,11 +1,12 @@
import { queries } from './searchQueries.json'; import { queries } from './searchQueries.json';
import { Query, SearchResult } from '../interfaces'; import { Query, SearchResult } from '../interfaces';
import { store } from '../store/store';
import { searchConfig } from '.'; import { searchConfig } from '.';
export const searchParser = (searchQuery: string): SearchResult => { export const searchParser = (searchQuery: string): SearchResult => {
const result: SearchResult = { const result: SearchResult = {
isLocal: false, isLocal: false,
isURL: false,
sameTab: false, sameTab: false,
search: '', search: '',
query: { query: {
@ -15,6 +16,15 @@ export const searchParser = (searchQuery: string): SearchResult => {
}, },
}; };
const customQueries = store.getState().config.customQueries;
// 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])$/;
result.isURL = urlRegex.test(searchQuery);
// Match prefix and query
const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i); const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i);
const prefix = splitQuery const prefix = splitQuery
@ -25,8 +35,11 @@ export const searchParser = (searchQuery: string): SearchResult => {
? encodeURIComponent(splitQuery[2]) ? encodeURIComponent(splitQuery[2])
: encodeURIComponent(searchQuery); : encodeURIComponent(searchQuery);
const query = queries.find((q: Query) => q.prefix === prefix); const query = [...queries, ...customQueries].find(
(q: Query) => q.prefix === prefix
);
// If search provider was found
if (query) { if (query) {
result.query = query; result.query = query;
result.search = search; result.search = search;

View file

@ -162,7 +162,7 @@ exports.getCss = asyncWrapper(async (req, res, next) => {
// @access Public // @access Public
exports.updateCss = asyncWrapper(async (req, res, next) => { exports.updateCss = asyncWrapper(async (req, res, next) => {
const file = new File(join(__dirname, '../public/flame.css')); const file = new File(join(__dirname, '../public/flame.css'));
file.write(req.body.styles); file.write(req.body.styles, false);
// Copy file to docker volume // Copy file to docker volume
fs.copyFileSync( fs.copyFileSync(

View file

@ -0,0 +1,81 @@
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,
});
});

View file

@ -5,10 +5,11 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"init-server": "echo Instaling server dependencies && npm install && mkdir public && touch public/flame.css", "init-server": "echo Instaling server dependencies && npm install",
"init-client": "cd client && echo Instaling client dependencies && npm install", "init-client": "cd client && echo Instaling client dependencies && npm install",
"dev-init": "npm run init-server && npm run init-client", "dir-init": "npx mkdirp data public && touch public/flame.css public/customQueries.json",
"dev-server": "nodemon server.js", "dev-init": "npm run dir-init && npm run init-server && npm run init-client",
"dev-server": "nodemon server.js -e js",
"dev-client": "npm start --prefix client", "dev-client": "npm start --prefix client",
"dev": "concurrently \"npm run dev-server\" \"npm run dev-client\"", "dev": "concurrently \"npm run dev-server\" \"npm run dev-client\"",
"skaffold": "concurrently \"npm run init-client\" \"npm run dev-server\"" "skaffold": "concurrently \"npm run init-client\" \"npm run dev-server\""

View file

@ -12,21 +12,10 @@ const {
getCss, getCss,
} = require('../controllers/config'); } = require('../controllers/config');
router router.route('/').post(createPair).get(getAllPairs).put(updateValues);
.route('/')
.post(createPair)
.get(getAllPairs)
.put(updateValues);
router router.route('/:key').get(getSinglePair).put(updateValue).delete(deletePair);
.route('/:key')
.get(getSinglePair)
.put(updateValue)
.delete(deletePair);
router router.route('/0/css').get(getCss).put(updateCss);
.route('/0/css')
.get(getCss)
.put(updateCss);
module.exports = router; module.exports = router;

14
routes/queries.js Normal file
View file

@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const {
getQueries,
addQuery,
deleteQuery,
updateQuery,
} = require('../controllers/queries/');
router.route('/').post(addQuery).get(getQueries);
router.route('/:prefix').delete(deleteQuery).put(updateQuery);
module.exports = router;

View file

@ -1,23 +1,28 @@
require('dotenv').config(); require('dotenv').config();
const http = require('http'); const http = require('http');
// Database
const { connectDB } = require('./db'); const { connectDB } = require('./db');
const associateModels = require('./models/associateModels');
// Server
const api = require('./api'); const api = require('./api');
const jobs = require('./utils/jobs'); const jobs = require('./utils/jobs');
const Socket = require('./Socket'); const Socket = require('./Socket');
const Sockets = require('./Sockets'); const Sockets = require('./Sockets');
const associateModels = require('./models/associateModels');
const initConfig = require('./utils/initConfig'); // Utils
const findCss = require('./utils/findCss'); const initApp = require('./utils/init');
const Logger = require('./utils/Logger'); const Logger = require('./utils/Logger');
const logger = new Logger(); const logger = new Logger();
const PORT = process.env.PORT || 5005;
(async () => { (async () => {
const PORT = process.env.PORT || 5005;
// Init app
await connectDB(); await connectDB();
await associateModels(); await associateModels();
await initConfig(); await initApp();
findCss();
// Create server for Express API and WebSockets // Create server for Express API and WebSockets
const server = http.createServer(); const server = http.createServer();
@ -28,6 +33,8 @@ const PORT = process.env.PORT || 5005;
Sockets.registerSocket('weather', weatherSocket); Sockets.registerSocket('weather', weatherSocket);
server.listen(PORT, () => { server.listen(PORT, () => {
logger.log(`Server is running on port ${PORT} in ${process.env.NODE_ENV} mode`); logger.log(
}) `Server is running on port ${PORT} in ${process.env.NODE_ENV} mode`
})(); );
});
})();

View file

@ -3,7 +3,7 @@ const fs = require('fs');
class File { class File {
constructor(path) { constructor(path) {
this.path = path; this.path = path;
this.content = ''; this.content = null;
} }
read() { read() {
@ -16,10 +16,13 @@ class File {
} }
} }
write(data) { write(data, isJSON) {
this.content = data; this.content = data;
fs.writeFileSync(this.path, this.content); fs.writeFileSync(
this.path,
isJSON ? JSON.stringify(this.content) : this.content
);
} }
} }
module.exports = File; module.exports = File;

View file

@ -1,22 +0,0 @@
const fs = require('fs');
const { join } = require('path');
const Logger = require('./Logger');
const logger = new Logger();
// Check if flame.css exists in mounted docker volume. Create new file if not
const findCss = () => {
const srcPath = join(__dirname, '../data/flame.css');
const destPath = join(__dirname, '../public/flame.css');
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, destPath);
logger.log('Custom CSS file found');
return;
}
logger.log('Creating empty CSS file');
fs.writeFileSync(destPath, '');
}
module.exports = findCss;

32
utils/init/createFile.js Normal file
View file

@ -0,0 +1,32 @@
const fs = require('fs');
const { join } = require('path');
const Logger = require('../Logger');
const logger = new Logger();
const createFile = async (file) => {
const { name, msg, template, isJSON, paths } = file;
const srcPath = join(__dirname, paths.src, name);
const destPath = join(__dirname, paths.dest, name);
// Check if file exists
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, destPath);
if (process.env.NODE_ENV == 'development') {
logger.log(msg.found);
}
return;
}
// Create file if not
fs.writeFileSync(destPath, isJSON ? JSON.stringify(template) : template);
if (process.env.NODE_ENV == 'development') {
logger.log(msg.created);
}
};
module.exports = createFile;

9
utils/init/index.js Normal file
View file

@ -0,0 +1,9 @@
const initConfig = require('./initConfig');
const initFiles = require('./initFiles');
const initApp = async () => {
await initConfig();
await initFiles();
};
module.exports = initApp;

View file

@ -1,7 +1,8 @@
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const Config = require('../models/Config'); const Config = require('../../models/Config');
const { config } = require('./initialConfig.json'); const { config } = require('./initialConfig.json');
const Logger = require('./Logger');
const Logger = require('../Logger');
const logger = new Logger(); const logger = new Logger();
const initConfig = async () => { const initConfig = async () => {
@ -28,7 +29,10 @@ const initConfig = async () => {
} }
}); });
logger.log('Initial config created'); if (process.env.NODE_ENV == 'development') {
logger.log('Initial config created');
}
return; return;
}; };

8
utils/init/initFiles.js Normal file
View file

@ -0,0 +1,8 @@
const createFile = require('./createFile');
const { files } = require('./initialFiles.json');
const initFiles = async () => {
files.forEach(async (file) => await createFile(file));
};
module.exports = initFiles;

View file

@ -0,0 +1,32 @@
{
"files": [
{
"name": "flame.css",
"msg": {
"created": "Created empty CSS file",
"found": "Custom CSS file found"
},
"paths": {
"src": "../../data",
"dest": "../../public"
},
"template": "",
"isJSON": false
},
{
"name": "customQueries.json",
"msg": {
"created": "Created empty queries file",
"found": "Custom queries file found"
},
"paths": {
"src": "../../data",
"dest": "../../data"
},
"template": {
"queries": []
},
"isJSON": true
}
]
}