diff --git a/README.md b/README.md index 3b2ed3d..d61baa6 100644 --- a/README.md +++ b/README.md @@ -59,4 +59,17 @@ docker run -p 5005:5005 -v :/app/data flame - Themes - Customize your page by choosing from 12 color themes -![Homescreen screenshot](./github/_themes.png) \ No newline at end of file +![Homescreen screenshot](./github/_themes.png) + +## Usage +### Supported URL formats for applications and bookmarks +#### Rules +- URL starts with `http://` + - Format: `http://www.domain.com`, `http://domain.com` + - Redirect: `{dest}` +- URL starts with `https://` + - Format: `https://www.domain.com`, `https://domain.com` + - Redirect: `https://{dest}` +- URL without protocol + - Format: `www.domain.com`, `domain.com`, `sub.domain.com`, `local`, `ip`, `ip:port` + - Redirect: `http://{dest}` \ No newline at end of file diff --git a/client/src/App.module.css b/client/src/App.module.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/App.tsx b/client/src/App.tsx index 9875f16..7210f3d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,27 +1,23 @@ import { BrowserRouter, Route, Switch } from 'react-router-dom'; -import { setTheme } from './store/actions'; +import { getConfig, setTheme } from './store/actions'; // Redux -import store from './store/store'; +import { store } from './store/store'; import { Provider } from 'react-redux'; -import classes from './App.module.css'; - import Home from './components/Home/Home'; import Apps from './components/Apps/Apps'; import Settings from './components/Settings/Settings'; import Bookmarks from './components/Bookmarks/Bookmarks'; - import NotificationCenter from './components/NotificationCenter/NotificationCenter'; +// Get config pairs from database +store.dispatch(getConfig()); + if (localStorage.theme) { store.dispatch(setTheme(localStorage.theme)); } -if (localStorage.customTitle) { - document.title = localStorage.customTitle; -} - const App = (): JSX.Element => { return ( diff --git a/client/src/components/Apps/AppCard/AppCard.tsx b/client/src/components/Apps/AppCard/AppCard.tsx index 18b5dfd..50fb979 100644 --- a/client/src/components/Apps/AppCard/AppCard.tsx +++ b/client/src/components/Apps/AppCard/AppCard.tsx @@ -1,8 +1,6 @@ -import { Link } from 'react-router-dom'; - import classes from './AppCard.module.css'; import Icon from '../../UI/Icons/Icon/Icon'; -import { iconParser } from '../../../utility/iconParser'; +import { iconParser, urlParser } from '../../../utility'; import { App } from '../../../interfaces'; @@ -12,18 +10,21 @@ interface ComponentProps { } const AppCard = (props: ComponentProps): JSX.Element => { - const redirectHandler = (url: string): void => { - window.open(url); - } + const [displayUrl, redirectUrl] = urlParser(props.app.url); return ( - +
{props.app.name}
- {props.app.url} + {displayUrl}
) diff --git a/client/src/components/Apps/AppForm/AppForm.tsx b/client/src/components/Apps/AppForm/AppForm.tsx index b833752..e9c7beb 100644 --- a/client/src/components/Apps/AppForm/AppForm.tsx +++ b/client/src/components/Apps/AppForm/AppForm.tsx @@ -98,7 +98,15 @@ const AppForm = (props: ComponentProps): JSX.Element => { value={formData.url} onChange={(e) => inputChangeHandler(e)} /> - Only urls without http[s]:// are supported + + + {' '}Check supported URL formats + + diff --git a/client/src/components/Apps/Apps.tsx b/client/src/components/Apps/Apps.tsx index 0f8e079..f02fba6 100644 --- a/client/src/components/Apps/Apps.tsx +++ b/client/src/components/Apps/Apps.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; // Redux @@ -30,6 +30,12 @@ interface ComponentProps { } const Apps = (props: ComponentProps): JSX.Element => { + const { + getApps, + apps, + loading + } = props; + const [modalIsOpen, setModalIsOpen] = useState(false); const [isInEdit, setIsInEdit] = useState(false); const [isInUpdate, setIsInUpdate] = useState(false); @@ -44,10 +50,10 @@ const Apps = (props: ComponentProps): JSX.Element => { }) useEffect(() => { - if (props.apps.length === 0) { - props.getApps(); + if (apps.length === 0) { + getApps(); } - }, [props.getApps]); + }, [getApps, apps]); const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); @@ -93,10 +99,10 @@ const Apps = (props: ComponentProps): JSX.Element => {
- {props.loading + {loading ? : (!isInEdit - ? + ? : ) }
diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx index 5f607b2..9e8dff9 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx @@ -2,7 +2,7 @@ import { Bookmark, Category } from '../../../interfaces'; import classes from './BookmarkCard.module.css'; import Icon from '../../UI/Icons/Icon/Icon'; -import { iconParser } from '../../../utility/iconParser'; +import { iconParser, urlParser } from '../../../utility'; interface ComponentProps { category: Category; @@ -13,19 +13,24 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {

{props.category.name}

- {props.category.bookmarks.map((bookmark: Bookmark) => ( - - {bookmark.icon && ( -
- -
- )} - {bookmark.name} -
- ))} + {props.category.bookmarks.map((bookmark: Bookmark) => { + const redirectUrl = urlParser(bookmark.url)[1]; + + return ( + + {bookmark.icon && ( +
+ +
+ )} + {bookmark.name} +
+ ) + })}
) diff --git a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx index c5f5bcf..eb83013 100644 --- a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx +++ b/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx @@ -184,7 +184,15 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { value={formData.url} onChange={(e) => inputChangeHandler(e)} /> - Only urls without http[s]:// are supported + + + {' '}Check supported URL formats + +
diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx index c6355c6..c316f31 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx @@ -2,7 +2,7 @@ import { Link } from 'react-router-dom'; import classes from './BookmarkGrid.module.css'; -import { Bookmark, Category } from '../../../interfaces'; +import { Category } from '../../../interfaces'; import BookmarkCard from '../BookmarkCard/BookmarkCard'; diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 53cca5c..b386706 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/client/src/components/Bookmarks/Bookmarks.tsx @@ -28,6 +28,12 @@ export enum ContentType { } const Bookmarks = (props: ComponentProps): JSX.Element => { + const { + getCategories, + categories, + loading + } = props; + const [modalIsOpen, setModalIsOpen] = useState(false); const [formContentType, setFormContentType] = useState(ContentType.category); const [isInEdit, setIsInEdit] = useState(false); @@ -52,10 +58,10 @@ const Bookmarks = (props: ComponentProps): JSX.Element => { }) useEffect(() => { - if (props.categories.length === 0) { - props.getCategories(); + if (categories.length === 0) { + getCategories(); } - }, [props.getCategories]) + }, [getCategories, categories]) const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); @@ -132,13 +138,13 @@ const Bookmarks = (props: ComponentProps): JSX.Element => { /> - {props.loading + {loading ? : (!isInEdit - ? + ? : ) diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index 1fdd090..854c04f 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; // Redux @@ -23,6 +23,13 @@ import AppGrid from '../Apps/AppGrid/AppGrid'; import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid'; import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget'; +// Functions +import { greeter } from './functions/greeter'; +import { dateTime } from './functions/dateTime'; + +// Utils +import { searchConfig } from '../../utility'; + interface ComponentProps { getApps: Function; getCategories: Function; @@ -33,68 +40,84 @@ interface ComponentProps { } const Home = (props: ComponentProps): JSX.Element => { + const { + getApps, + apps, + appsLoading, + getCategories, + categories, + categoriesLoading + } = props; + + const [header, setHeader] = useState({ + dateTime: dateTime(), + greeting: greeter() + }) + + // Load applications useEffect(() => { - if (props.apps.length === 0) { - props.getApps(); + if (apps.length === 0) { + getApps(); } - }, [props.getApps]); + }, [getApps, apps]); + // Load bookmark categories useEffect(() => { - if (props.categories.length === 0) { - props.getCategories(); + if (categories.length === 0) { + getCategories(); } - }, [props.getCategories]); + }, [getCategories, categories]); - const dateAndTime = (): string => { - const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + // Refresh greeter and time + useEffect(() => { + let interval: any; - const now = new Date(); - - return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`; - } - - const greeter = (): string => { - const now = new Date().getHours(); - let msg: string; - - if (now >= 18) msg = 'Good evening!'; - else if (now >= 12) msg = 'Good afternoon!'; - else if (now >= 6) msg = 'Good morning!'; - else if (now >= 0) msg = 'Good night!'; - else msg = 'Hello!'; - - return msg; - } + // Start interval only when hideHeader is false + if (searchConfig('hideHeader', 0) !== 1) { + interval = setInterval(() => { + setHeader({ + dateTime: dateTime(), + greeting: greeter() + }) + }, 1000); + } + return () => clearInterval(interval); + }, []) + return ( -
-

{dateAndTime()}

- Go to Settings - -

{greeter()}

- -
-
+ {searchConfig('hideHeader', 0) !== 1 + ? ( +
+

{header.dateTime}

+ Go to Settings + +

{header.greeting}

+ +
+
+ ) + :
+ } - {props.appsLoading + {appsLoading ? : app.isPinned)} - totalApps={props.apps.length} + apps={apps.filter((app: App) => app.isPinned)} + totalApps={apps.length} /> }
- {props.categoriesLoading + {categoriesLoading ? : category.isPinned)} - totalCategories={props.categories.length} + categories={categories.filter((category: Category) => category.isPinned)} + totalCategories={categories.length} /> } diff --git a/client/src/components/Home/functions/dateTime.ts b/client/src/components/Home/functions/dateTime.ts new file mode 100644 index 0000000..44cc5e1 --- /dev/null +++ b/client/src/components/Home/functions/dateTime.ts @@ -0,0 +1,8 @@ +export const dateTime = (): string => { + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + + const now = new Date(); + + return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`; +} \ No newline at end of file diff --git a/client/src/components/Home/functions/greeter.ts b/client/src/components/Home/functions/greeter.ts new file mode 100644 index 0000000..64cb2ea --- /dev/null +++ b/client/src/components/Home/functions/greeter.ts @@ -0,0 +1,12 @@ +export const greeter = (): string => { + const now = new Date().getHours(); + let msg: string; + + if (now >= 18) msg = 'Good evening!'; + else if (now >= 12) msg = 'Good afternoon!'; + else if (now >= 6) msg = 'Good morning!'; + else if (now >= 0) msg = 'Good night!'; + else msg = 'Hello!'; + + return msg; +} \ No newline at end of file diff --git a/client/src/components/Settings/OtherSettings/OtherSettings.tsx b/client/src/components/Settings/OtherSettings/OtherSettings.tsx index 7a6090e..5df8be7 100644 --- a/client/src/components/Settings/OtherSettings/OtherSettings.tsx +++ b/client/src/components/Settings/OtherSettings/OtherSettings.tsx @@ -1,69 +1,56 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; -import axios from 'axios'; -import { connect } from 'react-redux'; +// Redux +import { connect } from 'react-redux'; +import { createNotification, updateConfig } from '../../../store/actions'; + +// Typescript +import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces'; + +// UI import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import Button from '../../UI/Buttons/Button/Button'; -import { createNotification } from '../../../store/actions'; -import { ApiResponse, Config, NewNotification } from '../../../interfaces'; -interface FormState { - customTitle: string; - pinAppsByDefault: number; - pinCategoriesByDefault: number; -} +// Utils +import { searchConfig } from '../../../utility'; interface ComponentProps { createNotification: (notification: NewNotification) => void; + updateConfig: (formData: SettingsForm) => void; + loading: boolean; } const OtherSettings = (props: ComponentProps): JSX.Element => { - const [formData, setFormData] = useState({ + // Initial state + const [formData, setFormData] = useState({ customTitle: document.title, - pinAppsByDefault: 0, - pinCategoriesByDefault: 0 + pinAppsByDefault: 1, + pinCategoriesByDefault: 1, + hideHeader: 0 }) - // get initial config + // Get config useEffect(() => { - axios.get>('/api/config?keys=customTitle,pinAppsByDefault,pinCategoriesByDefault') - .then(data => { - let tmpFormData = { ...formData }; + setFormData({ + customTitle: searchConfig('customTitle', 'Flame'), + pinAppsByDefault: searchConfig('pinAppsByDefault', 1), + pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1), + hideHeader: searchConfig('hideHeader', 0) + }) + }, [props.loading]); - data.data.data.forEach((config: Config) => { - let value: string | number = config.value; - if (config.valueType === 'number') { - value = parseFloat(value); - } - - tmpFormData = { - ...tmpFormData, - [config.key]: value - } - }) - - setFormData(tmpFormData); - }) - .catch(err => console.log(err)); - }, []) - - const formSubmitHandler = (e: FormEvent) => { + // Form handler + const formSubmitHandler = async (e: FormEvent) => { e.preventDefault(); - axios.put>('/api/config', formData) - .then(() => { - props.createNotification({ - title: 'Success', - message: 'Settings updated' - }) - }) - .catch((err) => console.log(err)); + // Save settings + await props.updateConfig(formData); // update local page title - localStorage.setItem('customTitle', formData.customTitle); document.title = formData.customTitle; } + // Input handler const inputChangeHandler = (e: ChangeEvent, isNumber?: boolean) => { let value: string | number = e.target.value; @@ -80,7 +67,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { return (
formSubmitHandler(e)}> - + { + + + +
) } -export default connect(null, { createNotification })(OtherSettings); \ No newline at end of file +const mapStateToProps = (state: GlobalState) => { + return { + loading: state.config.loading + } +} + +export default connect(mapStateToProps, { createNotification, updateConfig })(OtherSettings); \ No newline at end of file diff --git a/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx b/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx index 3294fe4..912aced 100644 --- a/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx +++ b/client/src/components/Settings/WeatherSettings/WeatherSettings.tsx @@ -1,31 +1,77 @@ import { useState, ChangeEvent, useEffect, FormEvent } from 'react'; -import { connect } from 'react-redux'; import axios from 'axios'; -import { ApiResponse, Config, NewNotification, Weather } from '../../../interfaces'; +// Redux +import { connect } from 'react-redux'; +import { createNotification, updateConfig } from '../../../store/actions'; + +// Typescript +import { ApiResponse, GlobalState, NewNotification, Weather, WeatherForm } from '../../../interfaces'; + +// UI import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import Button from '../../UI/Buttons/Button/Button'; -import { createNotification } from '../../../store/actions'; -interface FormState { - WEATHER_API_KEY: string; - lat: number; - long: number; - isCelsius: number; -} +// Utils +import { searchConfig } from '../../../utility'; interface ComponentProps { createNotification: (notification: NewNotification) => void; + updateConfig: (formData: WeatherForm) => void; + loading: boolean; } const WeatherSettings = (props: ComponentProps): JSX.Element => { - const [formData, setFormData] = useState({ + // Initial state + const [formData, setFormData] = useState({ WEATHER_API_KEY: '', lat: 0, long: 0, isCelsius: 1 }) + // Get config + useEffect(() => { + setFormData({ + WEATHER_API_KEY: searchConfig('WEATHER_API_KEY', ''), + lat: searchConfig('lat', 0), + long: searchConfig('long', 0), + isCelsius: searchConfig('isCelsius', 1) + }) + }, [props.loading]); + + // Form handler + const formSubmitHandler = async (e: FormEvent) => { + e.preventDefault(); + + // Check for api key input + if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) { + props.createNotification({ + title: 'Warning', + message: 'API key is missing. Weather Module will NOT work' + }) + } + + // Save settings + await props.updateConfig(formData); + + // Update weather + axios.get>('/api/weather/update') + .then(() => { + props.createNotification({ + title: 'Success', + message: 'Weather updated' + }) + }) + .catch((err) => { + props.createNotification({ + title: 'Error', + message: err.response.data.error + }) + }); + } + + // Input handler const inputChangeHandler = (e: ChangeEvent, isNumber?: boolean) => { let value: string | number = e.target.value; @@ -39,72 +85,10 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => { }) } - useEffect(() => { - axios.get>('/api/config?keys=WEATHER_API_KEY,lat,long,isCelsius') - .then(data => { - let tmpFormData = { ...formData }; - - data.data.data.forEach((config: Config) => { - let value: string | number = config.value; - if (config.valueType === 'number') { - value = parseFloat(value); - } - - tmpFormData = { - ...tmpFormData, - [config.key]: value - } - }) - - setFormData(tmpFormData); - }) - .catch(err => console.log(err)); - }, []); - - const formSubmitHandler = (e: FormEvent) => { - e.preventDefault(); - - // Check for api key input - if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) { - props.createNotification({ - title: 'Warning', - message: 'API Key is missing. Weather Module will NOT work' - }) - } - - // Save settings - axios.put>('/api/config', formData) - .then(() => { - props.createNotification({ - title: 'Success', - message: 'Settings updated' - }) - - // Update weather with new settings - axios.get>('/api/weather/update') - .then(() => { - props.createNotification({ - title: 'Success', - message: 'Weather updated' - }) - }) - .catch((err) => { - props.createNotification({ - title: 'Error', - message: err.response.data.error - }) - }); - }) - .catch(err => console.log(err)); - - // set localStorage - localStorage.setItem('isCelsius', JSON.stringify(parseInt(`${formData.isCelsius}`) === 1)) - } - return (
formSubmitHandler(e)}> - + { - + { - + { /> - +