diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8e013d6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch via NPM", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run-script", "dev-server" + ] + } + ] +} \ No newline at end of file diff --git a/client/src/components/Apps/AppCard/AppCard.module.css b/client/src/components/Apps/AppCard/AppCard.module.css index d6b13a8..bfb4880 100644 --- a/client/src/components/Apps/AppCard/AppCard.module.css +++ b/client/src/components/Apps/AppCard/AppCard.module.css @@ -1,10 +1,15 @@ .AppCard { - width: 100%; - display: flex; - align-items: center; margin-bottom: 20px; } +.AppCard h3 { + color: var(--color-accent); + margin-bottom: 10px; + font-size: 16px; + font-weight: 400; + text-transform: uppercase; +} + .AppCardIcon { width: 35px; height: 35px; @@ -19,7 +24,7 @@ font-size: 1em; font-weight: 500; color: var(--color-primary); - margin-bottom: -4px; + margin-bottom: -10px; } .AppCardDetails span { @@ -29,16 +34,24 @@ opacity: 1; } -@media (min-width: 500px) { - .AppCard { - padding: 2px; - border-radius: 4px; - transition: all 0.1s; - } +.Apps { + display: flex; + flex-direction: column; + padding: 2px; + border-radius: 4px; + transition: all 0.1s; +} - .AppCard:hover { - background-color: rgba(0, 0, 0, 0.2); - } +.Apps a { + line-height: 2; + transition: all 0.25s; + display: flex; + width: 100%; + align-items: center; +} + +.Apps a:hover { + background-color: rgba(0, 0, 0, 0.2); } .CustomIcon { diff --git a/client/src/components/Apps/AppCard/AppCard.tsx b/client/src/components/Apps/AppCard/AppCard.tsx index 172a680..b6212d5 100644 --- a/client/src/components/Apps/AppCard/AppCard.tsx +++ b/client/src/components/Apps/AppCard/AppCard.tsx @@ -1,57 +1,64 @@ -import classes from './AppCard.module.css'; -import Icon from '../../UI/Icons/Icon/Icon'; +import { App, Category } from '../../../interfaces'; import { iconParser, urlParser } from '../../../utility'; - -import { App } from '../../../interfaces'; -import { searchConfig } from '../../../utility'; +import Icon from '../../UI/Icons/Icon/Icon'; +import classes from './AppCard.module.css'; interface ComponentProps { - app: App; + category: Category; + apps: App[] pinHandler?: Function; } const AppCard = (props: ComponentProps): JSX.Element => { - const [displayUrl, redirectUrl] = urlParser(props.app.url); - - let iconEl: JSX.Element; - const { icon } = props.app; - - if (/.(jpeg|jpg|png)$/i.test(icon)) { - iconEl = ( - {`${props.app.name} - ); - } else if (/.(svg)$/i.test(icon)) { - iconEl = ( -
- -
- ); - } else { - iconEl = ; - } - return ( - -
{iconEl}
-
-
{props.app.name}
- {displayUrl} +
+

{props.category.name}

+
+ {props.apps.map((app: App) => { + const [displayUrl, redirectUrl] = urlParser(app.url); + + let iconEl: JSX.Element; + const { icon } = app; + + if (/.(jpeg|jpg|png)$/i.test(icon)) { + iconEl = ( + {`${app.name} + ); + } else if (/.(svg)$/i.test(icon)) { + iconEl = ( +
+ +
+ ); + } else { + iconEl = ; + } + + return ( +
+
{iconEl}
+
+
{app.name}
+ {displayUrl} +
+
+ ) + })}
- - ); -}; +
+ ) +} export default AppCard; diff --git a/client/src/components/Apps/AppForm/AppForm.tsx b/client/src/components/Apps/AppForm/AppForm.tsx index d44418e..83dca72 100644 --- a/client/src/components/Apps/AppForm/AppForm.tsx +++ b/client/src/components/Apps/AppForm/AppForm.tsx @@ -1,59 +1,77 @@ -import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react'; +import { ChangeEvent, Dispatch, Fragment, SetStateAction, SyntheticEvent, useEffect, useState } from 'react'; import { connect } from 'react-redux'; -import { addApp, updateApp } from '../../../store/actions'; -import { App, NewApp } from '../../../interfaces'; -import classes from './AppForm.module.css'; - -import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; +import { App, Category, GlobalState, NewApp, NewCategory, NewNotification } from '../../../interfaces'; +import { + addApp, + addAppCategory, + createNotification, + getAppCategories, + updateApp, + updateAppCategory, +} from '../../../store/actions'; import Button from '../../UI/Buttons/Button/Button'; +import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; +import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; +import { ContentType } from '../Apps'; +import classes from './AppForm.module.css'; interface ComponentProps { modalHandler: () => void; - addApp: (formData: NewApp | FormData) => any; - updateApp: (id: number, formData: NewApp | FormData) => any; + contentType: ContentType; + categories: Category[]; + category?: Category; app?: App; + addAppCategory: (formData: NewCategory) => void; + addApp: (formData: NewApp | FormData) => void; + updateAppCategory: (id: number, formData: NewCategory) => void; + updateApp: (id: number, formData: NewApp | FormData, previousCategoryId: number) => void; + createNotification: (notification: NewNotification) => void; } const AppForm = (props: ComponentProps): JSX.Element => { const [useCustomIcon, toggleUseCustomIcon] = useState(false); const [customIcon, setCustomIcon] = useState(null); - const [formData, setFormData] = useState({ + const [categoryData, setCategoryData] = useState({ + name: '', + type: 'apps' + }) + + const [appData, setAppData] = useState({ name: '', url: '', + categoryId: -1, icon: '', }); + // Load category data if provided for editing + useEffect(() => { + if (props.category) { + setCategoryData({ name: props.category.name, type: props.category.type }); + } else { + setCategoryData({ name: '', type: "apps" }); + } + }, [props.category]); + + // Load app data if provided for editing useEffect(() => { if (props.app) { - setFormData({ + setAppData({ name: props.app.name, url: props.app.url, + categoryId: props.app.categoryId, icon: props.app.icon, }); } else { - setFormData({ + setAppData({ name: '', url: '', + categoryId: -1, icon: '', }); } }, [props.app]); - const inputChangeHandler = (e: ChangeEvent): void => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }); - }; - - const fileChangeHandler = (e: ChangeEvent): void => { - if (e.target.files) { - setCustomIcon(e.target.files[0]); - } - }; - const formSubmitHandler = (e: SyntheticEvent): void => { e.preventDefault(); @@ -63,132 +81,241 @@ const AppForm = (props: ComponentProps): JSX.Element => { if (customIcon) { data.append('icon', customIcon); } - data.append('name', formData.name); - data.append('url', formData.url); + data.append('name', appData.name); + data.append('url', appData.url); + data.append('categoryId', appData.categoryId.toString()); return data; }; - if (!props.app) { - if (customIcon) { - const data = createFormData(); - props.addApp(data); - } else { - props.addApp(formData); + if (!props.category && !props.app) { + // Add new + if (props.contentType === ContentType.category) { + // Add category + props.addAppCategory(categoryData); + setCategoryData({ name: '', type: 'apps' }); + } else if (props.contentType === ContentType.app) { + // Add app + if (appData.categoryId === -1) { + props.createNotification({ + title: 'Error', + message: 'Please select category' + }) + return; + } + if (customIcon) { + const data = createFormData(); + props.addApp(data); + } else { + props.addApp(appData); + } + setAppData({ + name: '', + url: '', + categoryId: appData.categoryId, + icon: '' + }) } } else { - if (customIcon) { - const data = createFormData(); - props.updateApp(props.app.id, data); - } else { - props.updateApp(props.app.id, formData); - props.modalHandler(); + // Update + if (props.contentType === ContentType.category && props.category) { + // Update category + props.updateAppCategory(props.category.id, categoryData); + setCategoryData({ name: '', type: 'apps' }); + } else if (props.contentType === ContentType.app && props.app) { + // Update app + if (customIcon) { + const data = createFormData(); + props.updateApp(props.app.id, data, props.app.categoryId); + } else { + props.updateApp(props.app.id, appData, props.app.categoryId); + props.modalHandler(); + } } - } - setFormData({ - name: '', - url: '', - icon: '', - }); - }; + setAppData({ + name: '', + url: '', + categoryId: -1, + icon: '' + }); + + setCustomIcon(null); + } + } + + const inputChangeHandler = (e: ChangeEvent, setDataFunction: Dispatch>, data: any): void => { + setDataFunction({ + ...data, + [e.target.name]: e.target.value + }) + } + + const fileChangeHandler = (e: ChangeEvent): void => { + if (e.target.files) { + setCustomIcon(e.target.files[0]); + } + } + + let button = + + if (!props.category && !props.app) { + if (props.contentType === ContentType.category) { + button = ; + } else { + button = ; + } + } else if (props.category) { + button = + } else if (props.app) { + button = + } return ( - - - inputChangeHandler(e)} - /> - - - - inputChangeHandler(e)} - /> - - - {' '} - Check supported URL formats - - - - {!useCustomIcon ? ( - // use mdi icon - - - inputChangeHandler(e)} - /> - - Use icon name from MDI. - - {' '} - Click here for reference - - - toggleUseCustomIcon(!useCustomIcon)} - className={classes.Switch} - > - Switch to custom icon upload - - - ) : ( - // upload custom icon - - - fileChangeHandler(e)} - accept=".jpg,.jpeg,.png,.svg" - /> - { - setCustomIcon(null); - toggleUseCustomIcon(!useCustomIcon); - }} - className={classes.Switch} - > - Switch to MDI - - - )} - {!props.app ? ( - - ) : ( - - )} + {props.contentType === ContentType.category + ? ( + + + + inputChangeHandler(e, setCategoryData, categoryData)} + /> + + + ) + : ( + + + + inputChangeHandler(e, setAppData, appData)} + /> + + + + inputChangeHandler(e, setAppData, appData)} + /> + + + {' '}Check supported URL formats + + + + + + + + {!useCustomIcon + // use mdi icon + ? ( + + inputChangeHandler(e, setAppData, appData)} + /> + + Use icon name from MDI. + + {' '}Click here for reference + + + toggleUseCustomIcon(!useCustomIcon)} + className={classes.Switch}> + Switch to custom icon upload + + ) + // upload custom icon + : ( + + fileChangeHandler(e)} + accept=".jpg,.jpeg,.png,.svg" + /> + toggleUseCustomIcon(!useCustomIcon)} + className={classes.Switch}> + Switch to MDI + + ) + } + + ) + } + {button} ); }; -export default connect(null, { addApp, updateApp })(AppForm); +const mapStateToProps = (state: GlobalState) => { + return { + categories: state.app.categories + } +} + +const dispatchMap = { + getAppCategories, + addAppCategory, + addApp, + updateAppCategory, + updateApp, + createNotification +} + +export default connect(mapStateToProps, dispatchMap)(AppForm); diff --git a/client/src/components/Apps/AppGrid/AppGrid.tsx b/client/src/components/Apps/AppGrid/AppGrid.tsx index 30d5c8c..a29adad 100644 --- a/client/src/components/Apps/AppGrid/AppGrid.tsx +++ b/client/src/components/Apps/AppGrid/AppGrid.tsx @@ -1,28 +1,29 @@ -import classes from './AppGrid.module.css'; import { Link } from 'react-router-dom'; -import { App } from '../../../interfaces/App'; +import { App, Category } from '../../../interfaces'; import AppCard from '../AppCard/AppCard'; +import classes from './AppGrid.module.css'; interface ComponentProps { - apps: App[]; - totalApps?: number; + categories: Category[]; + apps: App[] + totalCategories?: number; searching: boolean; } const AppGrid = (props: ComponentProps): JSX.Element => { let apps: JSX.Element; - if (props.apps.length > 0) { + if (props.categories.length > 0) { apps = (
- {props.apps.map((app: App): JSX.Element => { - return ; + {props.categories.map((category: Category): JSX.Element => { + return app.categoryId === category.id)} /> })}
); } else { - if (props.totalApps) { + if (props.totalCategories) { if (props.searching) { apps = (

@@ -32,7 +33,7 @@ const AppGrid = (props: ComponentProps): JSX.Element => { } else { apps = (

- There are no pinned applications. You can pin them from the{' '} + There are no pinned application categories. You can pin them from the{' '} /applications menu

); @@ -40,7 +41,7 @@ const AppGrid = (props: ComponentProps): JSX.Element => { } else { apps = (

- You don't have any applications. You can add a new one from{' '} + You don't have any applications. You can add a new one from the{' '} /applications menu

); diff --git a/client/src/components/Apps/AppTable/AppTable.tsx b/client/src/components/Apps/AppTable/AppTable.tsx index 6ef6e6c..eb97b84 100644 --- a/client/src/components/Apps/AppTable/AppTable.tsx +++ b/client/src/components/Apps/AppTable/AppTable.tsx @@ -1,73 +1,101 @@ -import { Fragment, KeyboardEvent, useState, useEffect } from 'react'; -import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; +import { Fragment, KeyboardEvent, useEffect, useState } from 'react'; +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; +import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -// Redux -import { connect } from 'react-redux'; -import { pinApp, deleteApp, reorderApps, updateConfig, createNotification } from '../../../store/actions'; - -// Typescript -import { App, GlobalState, NewNotification } from '../../../interfaces'; - -// CSS -import classes from './AppTable.module.css'; - -// UI +import { App, Category, NewNotification } from '../../../interfaces'; +import { + createNotification, + deleteApp, + deleteAppCategory, + pinApp, + pinAppCategory, + reorderAppCategories, + reorderApps, + updateConfig, +} from '../../../store/actions'; +import { searchConfig } from '../../../utility'; import Icon from '../../UI/Icons/Icon/Icon'; import Table from '../../UI/Table/Table'; - -// Utils -import { searchConfig } from '../../../utility'; +import { ContentType } from '../Apps'; +import classes from './AppTable.module.css'; interface ComponentProps { + contentType: ContentType; + categories: Category[]; apps: App[]; + pinAppCategory: (category: Category) => void; + deleteAppCategory: (id: number) => void; + reorderAppCategories: (categories: Category[]) => void; + updateHandler: (data: Category | App) => void; pinApp: (app: App) => void; deleteApp: (id: number) => void; - updateAppHandler: (app: App) => void; reorderApps: (apps: App[]) => void; updateConfig: (formData: any) => void; createNotification: (notification: NewNotification) => void; } const AppTable = (props: ComponentProps): JSX.Element => { + const [localCategories, setLocalCategories] = useState([]); const [localApps, setLocalApps] = useState([]); const [isCustomOrder, setIsCustomOrder] = useState(false); + // Copy categories array + useEffect(() => { + setLocalCategories([...props.categories]); + }, [props.categories]); + // Copy apps array useEffect(() => { setLocalApps([...props.apps]); - }, [props.apps]) + }, [props.apps]); // Check ordering useEffect(() => { - const order = searchConfig('useOrdering', ''); + const order = searchConfig("useOrdering", ""); - if (order === 'orderId') { + if (order === "orderId") { setIsCustomOrder(true); } - }, []) + }, []); + + const deleteCategoryHandler = (category: Category): void => { + const proceed = window.confirm( + `Are you sure you want to delete ${category.name}? It will delete ALL assigned apps` + ); + + if (proceed) { + props.deleteAppCategory(category.id); + } + }; const deleteAppHandler = (app: App): void => { - const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`); + const proceed = window.confirm( + `Are you sure you want to delete ${app.name} at ${app.url} ?` + ); if (proceed) { props.deleteApp(app.id); } - } + }; // Support keyboard navigation for actions - const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => { - if (e.key === 'Enter') { - handler(app); + const keyboardActionHandler = ( + e: KeyboardEvent, + object: any, + handler: Function + ) => { + if (e.key === "Enter") { + handler(object); } - } + }; - const dragEndHanlder = (result: DropResult): void => { + const dragEndHandler = (result: DropResult): void => { if (!isCustomOrder) { props.createNotification({ - title: 'Error', - message: 'Custom order is disabled' - }) + title: "Error", + message: "Custom order is disabled", + }); return; } @@ -75,106 +103,251 @@ const AppTable = (props: ComponentProps): JSX.Element => { return; } - const tmpApps = [...localApps]; - const [movedApp] = tmpApps.splice(result.source.index, 1); - tmpApps.splice(result.destination.index, 0, movedApp); + if (props.contentType === ContentType.app) { + const tmpApps = [...localApps]; + const [movedApp] = tmpApps.splice(result.source.index, 1); + tmpApps.splice(result.destination.index, 0, movedApp); - setLocalApps(tmpApps); - props.reorderApps(tmpApps); - } + setLocalApps(tmpApps); + props.reorderApps(tmpApps); + } else if (props.contentType === ContentType.category) { + const tmpCategories = [...localCategories]; + const [movedCategory] = tmpCategories.splice(result.source.index, 1); + tmpCategories.splice(result.destination.index, 0, movedCategory); - return ( - -
- {isCustomOrder - ?

You can drag and drop single rows to reorder application

- :

Custom order is disabled. You can change it in settings

- } -
- - - {(provided) => ( - - {localApps.map((app: App, index): JSX.Element => { - return ( - - {(provided, snapshot) => { - const style = { - border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none', - borderRadius: '4px', - ...provided.draggableProps.style, - }; - - return ( - - - - - {!snapshot.isDragging && ( - - )} - - ) - }} - - ) - })} -
{app.name}{app.url}{app.icon} -
deleteAppHandler(app)} - onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)} - tabIndex={0}> - -
-
props.updateAppHandler(app)} - onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)} - tabIndex={0}> - -
-
props.pinApp(app)} - onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)} - tabIndex={0}> - {app.isPinned - ? - : - } -
-
+ setLocalCategories(tmpCategories); + props.reorderAppCategories(tmpCategories); + } + }; + + if (props.contentType === ContentType.category) { + return ( + +
+ {isCustomOrder ? ( +

You can drag and drop single rows to reorder categories

+ ) : ( +

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

)} - - - - ) -} +
+ + + {(provided) => ( + + {localCategories.map( + (category: Category, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? "1px solid var(--color-accent)" + : "none", + borderRadius: "4px", + ...provided.draggableProps.style, + }; -const mapStateToProps = (state: GlobalState) => { - return { - apps: state.app.apps + return ( + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + } + )} +
{category.name} +
+ deleteCategoryHandler(category) + } + onKeyDown={(e) => + keyboardActionHandler( + e, + category, + deleteCategoryHandler + ) + } + tabIndex={0} + > + +
+
+ props.updateHandler(category) + } + tabIndex={0} + > + +
+
props.pinAppCategory(category)} + onKeyDown={(e) => + keyboardActionHandler( + e, + category, + props.pinAppCategory + ) + } + tabIndex={0} + > + {category.isPinned ? ( + + ) : ( + + )} +
+
+ )} +
+
+
+ ); + } else { + return ( + +
+ {isCustomOrder ? ( +

You can drag and drop single rows to reorder application

+ ) : ( +

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

+ )} +
+ + + {(provided) => ( + + {localApps.map((app: App, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? "1px solid var(--color-accent)" + : "none", + borderRadius: "4px", + ...provided.draggableProps.style, + }; + + const category = localCategories.find((category: Category) => category.id === app.categoryId); + const categoryName = category?.name; + + return ( + + + + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + })} +
{app.name}{app.url}{app.icon}{categoryName} +
deleteAppHandler(app)} + onKeyDown={(e) => + keyboardActionHandler( + e, + app, + deleteAppHandler + ) + } + tabIndex={0} + > + +
+
props.updateHandler(app)} + onKeyDown={(e) => + keyboardActionHandler( + e, + app, + props.updateHandler + ) + } + tabIndex={0} + > + +
+
props.pinApp(app)} + onKeyDown={(e) => + keyboardActionHandler(e, app, props.pinApp) + } + tabIndex={0} + > + {app.isPinned ? ( + + ) : ( + + )} +
+
+ )} +
+
+
+ ); } -} +}; const actions = { + pinAppCategory, + deleteAppCategory, + reorderAppCategories, pinApp, deleteApp, reorderApps, updateConfig, - createNotification -} + createNotification, +}; -export default connect(mapStateToProps, actions)(AppTable); \ No newline at end of file +export default connect(null, actions)(AppTable); diff --git a/client/src/components/Apps/Apps.tsx b/client/src/components/Apps/Apps.tsx index 751a196..f2049e4 100644 --- a/client/src/components/Apps/Apps.tsx +++ b/client/src/components/Apps/Apps.tsx @@ -1,45 +1,63 @@ import { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -// Redux -import { connect } from 'react-redux'; -import { getApps } from '../../store/actions'; - -// Typescript -import { App, GlobalState } from '../../interfaces'; - -// CSS -import classes from './Apps.module.css'; - -// UI -import { Container } from '../UI/Layout/Layout'; -import Headline from '../UI/Headlines/Headline/Headline'; -import Spinner from '../UI/Spinner/Spinner'; +import { App, Category, GlobalState } from '../../interfaces'; +import { getAppCategories, getApps } from '../../store/actions'; import ActionButton from '../UI/Buttons/ActionButton/ActionButton'; +import Headline from '../UI/Headlines/Headline/Headline'; +import { Container } from '../UI/Layout/Layout'; import Modal from '../UI/Modal/Modal'; - -// Subcomponents -import AppGrid from './AppGrid/AppGrid'; +import Spinner from '../UI/Spinner/Spinner'; import AppForm from './AppForm/AppForm'; +import AppGrid from './AppGrid/AppGrid'; +import classes from './Apps.module.css'; import AppTable from './AppTable/AppTable'; -interface ComponentProps { - getApps: Function; - apps: App[]; +interface ComponentProps { loading: boolean; + categories: Category[]; + getAppCategories: () => void; + apps: App[]; + getApps: () => void; searching: boolean; } +export enum ContentType { + category, + app +} + const Apps = (props: ComponentProps): JSX.Element => { - const { getApps, apps, loading, searching = false } = props; + const { + apps, + getApps, + getAppCategories, + categories, + loading, + searching = false + } = props; const [modalIsOpen, setModalIsOpen] = useState(false); + const [formContentType, setFormContentType] = useState(ContentType.category); const [isInEdit, setIsInEdit] = useState(false); + const [tableContentType, setTableContentType] = useState(ContentType.category); const [isInUpdate, setIsInUpdate] = useState(false); + const [categoryInUpdate, setCategoryInUpdate] = useState({ + name: "", + id: -1, + isPinned: false, + orderId: 0, + type: "apps", + bookmarks: [], + createdAt: new Date(), + updatedAt: new Date() + }) const [appInUpdate, setAppInUpdate] = useState({ - name: 'string', - url: 'string', - icon: 'string', + name: "string", + url: "string", + categoryId: -1, + icon: "string", isPinned: false, orderId: 0, id: 0, @@ -53,60 +71,93 @@ const Apps = (props: ComponentProps): JSX.Element => { } }, [getApps]); + useEffect(() => { + if (categories.length === 0) { + getAppCategories(); + } + }, [getAppCategories]) + const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); - setIsInUpdate(false); - }; + // setIsInUpdate(false); + } - const toggleEdit = (): void => { - setIsInEdit(!isInEdit); + const addActionHandler = (contentType: ContentType) => { + setFormContentType(contentType); setIsInUpdate(false); - }; + toggleModal(); + } - const toggleUpdate = (app: App): void => { - setAppInUpdate(app); + const editActionHandler = (contentType: ContentType) => { + // We"re in the edit mode and the same button was clicked - go back to list + if (isInEdit && contentType === tableContentType) { + setIsInEdit(false); + } else { + setIsInEdit(true); + setTableContentType(contentType); + } + } + + const instanceOfCategory = (object: any): object is Category => { + return "apps" in object; + } + + const goToUpdateMode = (data: Category | App): void => { setIsInUpdate(true); - setModalIsOpen(true); - }; + if (instanceOfCategory(data)) { + setFormContentType(ContentType.category); + setCategoryInUpdate(data); + } else { + setFormContentType(ContentType.app); + setAppInUpdate(data); + } + toggleModal(); + } return ( - + {!isInUpdate ? ( - + ) : ( - + formContentType === ContentType.category ? ( + + ) : ( + + ) )} Go back} + subtitle={(Go back)} />
- - + addActionHandler(ContentType.category)} /> + addActionHandler(ContentType.app)} /> + editActionHandler(ContentType.category)} /> + editActionHandler(ContentType.app)} />
-
- {loading ? ( - - ) : !isInEdit ? ( - - ) : ( - - )} -
+ {loading ? ( + + ) : (!isInEdit ? ( + + ) : ( + + ) + )}
); }; const mapStateToProps = (state: GlobalState) => { return { - apps: state.app.apps, loading: state.app.loading, - }; -}; + categories: state.app.categories, + apps: state.app.apps, + } +} -export default connect(mapStateToProps, { getApps })(Apps); +export default connect(mapStateToProps, { getApps, getAppCategories })(Apps); diff --git a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx index 5162c89..2cc51c6 100644 --- a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx +++ b/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx @@ -1,40 +1,19 @@ -// React -import { - useState, - SyntheticEvent, - Fragment, - ChangeEvent, - useEffect, -} from 'react'; - -// Redux +import { ChangeEvent, Dispatch, Fragment, SetStateAction, SyntheticEvent, useEffect, useState } from 'react'; import { connect } from 'react-redux'; + +import { Bookmark, Category, GlobalState, NewBookmark, NewCategory, NewNotification } from '../../../interfaces'; import { - getCategories, - addCategory, addBookmark, - updateCategory, - updateBookmark, + addBookmarkCategory, createNotification, + getBookmarkCategories, + updateBookmark, + updateBookmarkCategory, } 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'; - -// CSS +import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; +import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; +import { ContentType } from '../Bookmarks'; import classes from './BookmarkForm.module.css'; interface ComponentProps { @@ -43,28 +22,28 @@ interface ComponentProps { categories: Category[]; category?: Category; bookmark?: Bookmark; - addCategory: (formData: NewCategory) => void; + addBookmarkCategory: (formData: NewCategory) => void; addBookmark: (formData: NewBookmark | FormData) => void; - updateCategory: (id: number, formData: NewCategory) => void; + updateBookmarkCategory: (id: number, formData: NewCategory) => void; updateBookmark: ( - id: number, - formData: NewBookmark | FormData, + id: number, + formData: NewBookmark | FormData, category: { - prev: number; - curr: number; - } - ) => void; + prev: number, + curr: number + }) => void; createNotification: (notification: NewNotification) => void; } const BookmarkForm = (props: ComponentProps): JSX.Element => { - const [useCustomIcon, toggleUseCustomIcon] = useState(false); + const [useCustomIcon, setUseCustomIcon] = useState(false); const [customIcon, setCustomIcon] = useState(null); - const [categoryName, setCategoryName] = useState({ + const [categoryData, setCategoryData] = useState({ name: '', - }); + type: 'bookmarks' + }) - const [formData, setFormData] = useState({ + const [bookmarkData, setBookmarkData] = useState({ name: '', url: '', categoryId: -1, @@ -74,23 +53,23 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { // Load category data if provided for editing useEffect(() => { if (props.category) { - setCategoryName({ name: props.category.name }); + setCategoryData({ name: props.category.name, type: props.category.type }); } else { - setCategoryName({ name: '' }); + setCategoryData({ name: '', type: "bookmarks" }); } }, [props.category]); // Load bookmark data if provided for editing useEffect(() => { if (props.bookmark) { - setFormData({ + setBookmarkData({ name: props.bookmark.name, url: props.bookmark.url, categoryId: props.bookmark.categoryId, icon: props.bookmark.icon, }); } else { - setFormData({ + setBookmarkData({ name: '', url: '', categoryId: -1, @@ -107,9 +86,9 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { if (customIcon) { data.append('icon', customIcon); } - data.append('name', formData.name); - data.append('url', formData.url); - data.append('categoryId', `${formData.categoryId}`); + data.append('name', bookmarkData.name); + data.append('url', bookmarkData.url); + data.append('categoryId', `${bookmarkData.categoryId}`); return data; }; @@ -118,11 +97,11 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { // Add new if (props.contentType === ContentType.category) { // Add category - props.addCategory(categoryName); - setCategoryName({ name: '' }); + props.addBookmarkCategory(categoryData); + setCategoryData({ name: '', type: 'bookmarks' }); } else if (props.contentType === ContentType.bookmark) { // Add bookmark - if (formData.categoryId === -1) { + if (bookmarkData.categoryId === -1) { props.createNotification({ title: 'Error', message: 'Please select category', @@ -134,14 +113,14 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { const data = createFormData(); props.addBookmark(data); } else { - props.addBookmark(formData); + props.addBookmark(bookmarkData); } - - setFormData({ + + setBookmarkData({ name: '', url: '', - categoryId: formData.categoryId, - icon: '', + categoryId: bookmarkData.categoryId, + icon: '' }); // setCustomIcon(null); @@ -150,24 +129,32 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { // Update if (props.contentType === ContentType.category && props.category) { // Update category - props.updateCategory(props.category.id, categoryName); - setCategoryName({ name: '' }); + props.updateBookmarkCategory(props.category.id, categoryData); + setCategoryData({ name: '', type: 'bookmarks' }); } else if (props.contentType === ContentType.bookmark && props.bookmark) { // Update bookmark if (customIcon) { const data = createFormData(); - props.updateBookmark(props.bookmark.id, data, { - prev: props.bookmark.categoryId, - curr: formData.categoryId, - }); + props.updateBookmark( + props.bookmark.id, + data, + { + prev: props.bookmark.categoryId, + curr: bookmarkData.categoryId + } + ) } else { - props.updateBookmark(props.bookmark.id, formData, { - prev: props.bookmark.categoryId, - curr: formData.categoryId, - }); + props.updateBookmark( + props.bookmark.id, + bookmarkData, + { + prev: props.bookmark.categoryId, + curr: bookmarkData.categoryId + } + ); } - setFormData({ + setBookmarkData({ name: '', url: '', categoryId: -1, @@ -181,18 +168,16 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { } }; - const inputChangeHandler = (e: ChangeEvent): void => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, + const inputChangeHandler = (e: ChangeEvent, setDataFunction: Dispatch>, data: any): void => { + setDataFunction({ + ...data, + [e.target.name]: e.target.value }); }; - const selectChangeHandler = (e: ChangeEvent): void => { - setFormData({ - ...formData, - categoryId: parseInt(e.target.value), - }); + const toggleUseCustomIcon = (): void => { + setUseCustomIcon(!useCustomIcon); + setCustomIcon(null); }; const fileChangeHandler = (e: ChangeEvent): void => { @@ -230,8 +215,8 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { id="categoryName" placeholder="Social Media" required - value={categoryName.name} - onChange={(e) => setCategoryName({ name: e.target.value })} + value={categoryData.name} + onChange={(e) => inputChangeHandler(e, setCategoryData, categoryData)} />
@@ -245,8 +230,8 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { id="name" placeholder="Reddit" required - value={formData.name} - onChange={(e) => inputChangeHandler(e)} + value={bookmarkData.name} + onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)} /> @@ -257,8 +242,8 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { id="url" placeholder="reddit.com" required - value={formData.url} - onChange={(e) => inputChangeHandler(e)} + value={bookmarkData.url} + onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)} /> { name="categoryId" id="categoryId" required - onChange={(e) => selectChangeHandler(e)} - value={formData.categoryId} + value={bookmarkData.categoryId} + onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)} > {props.categories.map((category: Category): JSX.Element => { @@ -295,12 +280,12 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { inputChangeHandler(e)} + type='text' + name='icon' + id='icon' + placeholder='book-open-outline' + value={bookmarkData.icon} + onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)} /> Use icon name from MDI. @@ -310,7 +295,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { toggleUseCustomIcon(!useCustomIcon)} + onClick={() => toggleUseCustomIcon()} className={classes.Switch} > Switch to custom icon upload @@ -328,10 +313,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => { accept=".jpg,.jpeg,.png,.svg" /> { - setCustomIcon(null); - toggleUseCustomIcon(!useCustomIcon); - }} + onClick={() => toggleUseCustomIcon()} className={classes.Switch} > Switch to MDI @@ -352,10 +334,10 @@ const mapStateToProps = (state: GlobalState) => { }; const dispatchMap = { - getCategories, - addCategory, + getBookmarkCategories, + addBookmarkCategory, addBookmark, - updateCategory, + updateBookmarkCategory, updateBookmark, createNotification, }; diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx index bf17c81..8a7db3b 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx @@ -1,10 +1,8 @@ import { Link } from 'react-router-dom'; -import classes from './BookmarkGrid.module.css'; - import { Category } from '../../../interfaces'; - import BookmarkCard from '../BookmarkCard/BookmarkCard'; +import classes from './BookmarkGrid.module.css'; interface ComponentProps { categories: Category[]; @@ -37,7 +35,7 @@ const BookmarkGrid = (props: ComponentProps): JSX.Element => { if (props.totalCategories) { bookmarks = (

- There are no pinned categories. You can pin them from the{' '} + There are no pinned bookmark categories. You can pin them from the{' '} /bookmarks menu

); diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx index 02779d5..577b33f 100644 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx +++ b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx @@ -1,34 +1,31 @@ -import { KeyboardEvent, useState, useEffect, Fragment } from 'react'; -import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; +import { Fragment, KeyboardEvent, useEffect, useState } from 'react'; +import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; +import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -// Redux -import { connect } from 'react-redux'; -import { pinCategory, deleteCategory, deleteBookmark, createNotification, reorderCategories } from '../../../store/actions'; - -// Typescript import { Bookmark, Category, NewNotification } from '../../../interfaces'; -import { ContentType } from '../Bookmarks'; - -// CSS -import classes from './BookmarkTable.module.css'; - -// UI -import Table from '../../UI/Table/Table'; -import Icon from '../../UI/Icons/Icon/Icon'; - -// Utils +import { + createNotification, + deleteBookmark, + deleteBookmarkCategory, + pinBookmarkCategory, + reorderBookmarkCategories, +} from '../../../store/actions'; import { searchConfig } from '../../../utility'; +import Icon from '../../UI/Icons/Icon/Icon'; +import Table from '../../UI/Table/Table'; +import { ContentType } from '../Bookmarks'; +import classes from './BookmarkTable.module.css'; interface ComponentProps { contentType: ContentType; categories: Category[]; - pinCategory: (category: Category) => void; - deleteCategory: (id: number) => void; + pinBookmarkCategory: (category: Category) => void; + deleteBookmarkCategory: (id: number) => void; updateHandler: (data: Category | Bookmark) => void; deleteBookmark: (bookmarkId: number, categoryId: number) => void; createNotification: (notification: NewNotification) => void; - reorderCategories: (categories: Category[]) => void; + reorderBookmarkCategories: (categories: Category[]) => void; } const BookmarkTable = (props: ComponentProps): JSX.Element => { @@ -38,45 +35,53 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { // Copy categories array useEffect(() => { setLocalCategories([...props.categories]); - }, [props.categories]) + }, [props.categories]); // Check ordering useEffect(() => { - const order = searchConfig('useOrdering', ''); + const order = searchConfig("useOrdering", ""); - if (order === 'orderId') { + if (order === "orderId") { setIsCustomOrder(true); } - }) + }, []); const deleteCategoryHandler = (category: Category): void => { - const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`); + const proceed = window.confirm( + `Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks` + ); if (proceed) { - props.deleteCategory(category.id); + props.deleteBookmarkCategory(category.id); } - } + }; const deleteBookmarkHandler = (bookmark: Bookmark): void => { - const proceed = window.confirm(`Are you sure you want to delete ${bookmark.name}?`); + const proceed = window.confirm( + `Are you sure you want to delete ${bookmark.name}?` + ); if (proceed) { props.deleteBookmark(bookmark.id, bookmark.categoryId); } - } + }; - const keyboardActionHandler = (e: KeyboardEvent, category: Category, handler: Function) => { - if (e.key === 'Enter') { + const keyboardActionHandler = ( + e: KeyboardEvent, + category: Category, + handler: Function + ) => { + if (e.key === "Enter") { handler(category); } - } + }; - const dragEndHanlder = (result: DropResult): void => { + const dragEndHandler = (result: DropResult): void => { if (!isCustomOrder) { props.createNotification({ - title: 'Error', - message: 'Custom order is disabled' - }) + title: "Error", + message: "Custom order is disabled", + }); return; } @@ -85,141 +90,170 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { } const tmpCategories = [...localCategories]; - const [movedApp] = tmpCategories.splice(result.source.index, 1); - tmpCategories.splice(result.destination.index, 0, movedApp); + const [movedCategory] = tmpCategories.splice(result.source.index, 1); + tmpCategories.splice(result.destination.index, 0, movedCategory); setLocalCategories(tmpCategories); - props.reorderCategories(tmpCategories); - } + props.reorderBookmarkCategories(tmpCategories); + }; if (props.contentType === ContentType.category) { return (
- {isCustomOrder - ?

You can drag and drop single rows to reorder categories

- :

Custom order is disabled. You can change it in settings

- } + {isCustomOrder ? ( +

You can drag and drop single rows to reorder categories

+ ) : ( +

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

+ )}
- - + + {(provided) => ( - - {localCategories.map((category: Category, index): JSX.Element => { - return ( - - {(provided, snapshot) => { - const style = { - border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none', - borderRadius: '4px', - ...provided.draggableProps.style, - }; +
+ {localCategories.map( + (category: Category, index): JSX.Element => { + return ( + + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? "1px solid var(--color-accent)" + : "none", + borderRadius: "4px", + ...provided.draggableProps.style, + }; - return ( - - - {!snapshot.isDragging && ( - - )} - - ) - }} - - ) - })} + return ( + + + {!snapshot.isDragging && ( + + )} + + ); + }} + + ); + } + )}
{category.name} -
deleteCategoryHandler(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)} - tabIndex={0}> - -
-
props.updateHandler(category)} - tabIndex={0}> - -
-
props.pinCategory(category)} - onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)} - tabIndex={0}> - {category.isPinned - ? - : - } -
-
{category.name} +
+ deleteCategoryHandler(category) + } + onKeyDown={(e) => + keyboardActionHandler( + e, + category, + deleteCategoryHandler + ) + } + tabIndex={0} + > + +
+
+ props.updateHandler(category) + } + tabIndex={0} + > + +
+
props.pinBookmarkCategory(category)} + onKeyDown={(e) => + keyboardActionHandler( + e, + category, + props.pinBookmarkCategory + ) + } + tabIndex={0} + > + {category.isPinned ? ( + + ) : ( + + )} +
+
)}
- ) + ); } else { - const bookmarks: {bookmark: Bookmark, categoryName: string}[] = []; + const bookmarks: { bookmark: Bookmark; categoryName: string }[] = []; props.categories.forEach((category: Category) => { category.bookmarks.forEach((bookmark: Bookmark) => { bookmarks.push({ bookmark, - categoryName: category.name + categoryName: category.name, }); - }) - }) + }); + }); return ( - - {bookmarks.map((bookmark: {bookmark: Bookmark, categoryName: string}) => { - return ( - - - - - - - - ) - })} +
{bookmark.bookmark.name}{bookmark.bookmark.url}{bookmark.bookmark.icon}{bookmark.categoryName} -
deleteBookmarkHandler(bookmark.bookmark)} - tabIndex={0}> - -
-
props.updateHandler(bookmark.bookmark)} - tabIndex={0}> - -
-
+ {bookmarks.map( + (bookmark: { bookmark: Bookmark; categoryName: string }) => { + return ( + + + + + + + + ); + } + )}
{bookmark.bookmark.name}{bookmark.bookmark.url}{bookmark.bookmark.icon}{bookmark.categoryName} +
deleteBookmarkHandler(bookmark.bookmark)} + tabIndex={0} + > + +
+
props.updateHandler(bookmark.bookmark)} + tabIndex={0} + > + +
+
- ) + ); } -} +}; const actions = { - pinCategory, - deleteCategory, + pinBookmarkCategory, + deleteBookmarkCategory, deleteBookmark, createNotification, - reorderCategories -} + reorderBookmarkCategories, +}; -export default connect(null, actions)(BookmarkTable); \ No newline at end of file +export default connect(null, actions)(BookmarkTable); diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 88d9cdb..af658b7 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/client/src/components/Bookmarks/Bookmarks.tsx @@ -1,25 +1,23 @@ import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; import { connect } from 'react-redux'; -import { getCategories } from '../../store/actions'; +import { Link } from 'react-router-dom'; -import classes from './Bookmarks.module.css'; - -import { Container } from '../UI/Layout/Layout'; -import Headline from '../UI/Headlines/Headline/Headline'; +import { Bookmark, Category, GlobalState } from '../../interfaces'; +import { getBookmarkCategories } from '../../store/actions'; import ActionButton from '../UI/Buttons/ActionButton/ActionButton'; - -import BookmarkGrid from './BookmarkGrid/BookmarkGrid'; -import { Category, GlobalState, Bookmark } from '../../interfaces'; -import Spinner from '../UI/Spinner/Spinner'; +import Headline from '../UI/Headlines/Headline/Headline'; +import { Container } from '../UI/Layout/Layout'; import Modal from '../UI/Modal/Modal'; +import Spinner from '../UI/Spinner/Spinner'; import BookmarkForm from './BookmarkForm/BookmarkForm'; +import BookmarkGrid from './BookmarkGrid/BookmarkGrid'; +import classes from './Bookmarks.module.css'; import BookmarkTable from './BookmarkTable/BookmarkTable'; interface ComponentProps { loading: boolean; categories: Category[]; - getCategories: () => void; + getBookmarkCategories: () => void; searching: boolean; } @@ -29,7 +27,7 @@ export enum ContentType { } const Bookmarks = (props: ComponentProps): JSX.Element => { - const { getCategories, categories, loading, searching = false } = props; + const { getBookmarkCategories, categories, loading, searching = false } = props; const [modalIsOpen, setModalIsOpen] = useState(false); const [formContentType, setFormContentType] = useState(ContentType.category); @@ -43,6 +41,7 @@ const Bookmarks = (props: ComponentProps): JSX.Element => { id: -1, isPinned: false, orderId: 0, + type: 'bookmarks', bookmarks: [], createdAt: new Date(), updatedAt: new Date(), @@ -59,9 +58,9 @@ const Bookmarks = (props: ComponentProps): JSX.Element => { useEffect(() => { if (categories.length === 0) { - getCategories(); + getBookmarkCategories(); } - }, [getCategories]); + }, [getBookmarkCategories]); const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); @@ -169,4 +168,4 @@ const mapStateToProps = (state: GlobalState) => { }; }; -export default connect(mapStateToProps, { getCategories })(Bookmarks); +export default connect(mapStateToProps, { getBookmarkCategories })(Bookmarks); diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index fd711aa..088e056 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -1,53 +1,44 @@ -import { useState, useEffect, Fragment } from 'react'; +import { Fragment, useEffect, useState } from 'react'; +import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -// Redux -import { connect } from 'react-redux'; -import { getApps, getCategories } from '../../store/actions'; - -// Typescript +import { App, Bookmark, Category } from '../../interfaces'; import { GlobalState } from '../../interfaces/GlobalState'; -import { App, Category } from '../../interfaces'; - -// UI -import Icon from '../UI/Icons/Icon/Icon'; -import { Container } from '../UI/Layout/Layout'; -import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline'; -import Spinner from '../UI/Spinner/Spinner'; - -// CSS -import classes from './Home.module.css'; - -// Components +import { getAppCategories, getApps, getBookmarkCategories } from '../../store/actions'; +import { searchConfig } from '../../utility'; import AppGrid from '../Apps/AppGrid/AppGrid'; import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid'; -import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget'; import SearchBar from '../SearchBar/SearchBar'; - -// Functions -import { greeter } from './functions/greeter'; +import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline'; +import Icon from '../UI/Icons/Icon/Icon'; +import { Container } from '../UI/Layout/Layout'; +import Spinner from '../UI/Spinner/Spinner'; +import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget'; import { dateTime } from './functions/dateTime'; - -// Utils -import { searchConfig } from '../../utility'; +import { greeter } from './functions/greeter'; +import classes from './Home.module.css'; interface ComponentProps { - getApps: Function; - getCategories: Function; + getApps: () => void; + getAppCategories: () => void; + getBookmarkCategories: () => void; appsLoading: boolean; + bookmarkCategoriesLoading: boolean; + appCategories: Category[]; apps: App[]; - categoriesLoading: boolean; - categories: Category[]; + bookmarkCategories: Category[]; } const Home = (props: ComponentProps): JSX.Element => { const { + getAppCategories, getApps, + getBookmarkCategories, + appCategories, apps, + bookmarkCategories, appsLoading, - getCategories, - categories, - categoriesLoading, + bookmarkCategoriesLoading, } = props; const [header, setHeader] = useState({ @@ -58,7 +49,14 @@ const Home = (props: ComponentProps): JSX.Element => { // Local search query const [localSearch, setLocalSearch] = useState(null); - // Load applications + // Load app categories + useEffect(() => { + if (appCategories.length === 0) { + getAppCategories(); + } + }, [getAppCategories]); + + // Load apps useEffect(() => { if (apps.length === 0) { getApps(); @@ -67,10 +65,10 @@ const Home = (props: ComponentProps): JSX.Element => { // Load bookmark categories useEffect(() => { - if (categories.length === 0) { - getCategories(); + if (bookmarkCategories.length === 0) { + getBookmarkCategories(); } - }, [getCategories]); + }, [getBookmarkCategories]); // Refresh greeter and time useEffect(() => { @@ -90,13 +88,20 @@ const Home = (props: ComponentProps): JSX.Element => { }, []); // Search bookmarks - const searchBookmarks = (query: string): Category[] => { - const category = { ...categories[0] }; - category.name = 'Search Results'; - category.bookmarks = categories - .map(({ bookmarks }) => bookmarks) - .flat() - .filter(({ name }) => new RegExp(query, 'i').test(name)); + const searchBookmarks = (query: string, categoriesToSearch: Category[]): Category[] => { + const category: Category = { + name: "Search Results", + type: categoriesToSearch[0].type, + isPinned: true, + bookmarks: categoriesToSearch + .map((c: Category) => c.bookmarks) + .flat() + .filter((bookmark: Bookmark) => new RegExp(query, 'i').test(bookmark.name)), + id: 0, + orderId: 0, + createdAt: new Date(), + updatedAt: new Date(), + }; return [category]; }; @@ -131,14 +136,15 @@ const Home = (props: ComponentProps): JSX.Element => { ) : ( category.isPinned)} apps={ !localSearch - ? apps.filter(({ isPinned }) => isPinned) - : apps.filter(({ name }) => - new RegExp(localSearch, 'i').test(name) + ? apps.filter((app: App) => app.isPinned) + : apps.filter((app: App) => + new RegExp(localSearch, 'i').test(app.name) ) } - totalApps={apps.length} + totalCategories={appCategories.length} searching={!!localSearch} /> )} @@ -151,16 +157,16 @@ const Home = (props: ComponentProps): JSX.Element => { {searchConfig('hideCategories', 0) !== 1 ? ( - {categoriesLoading ? ( + {bookmarkCategoriesLoading ? ( ) : ( isPinned) - : searchBookmarks(localSearch) + ? bookmarkCategories.filter((category: Category) => category.isPinned) + : searchBookmarks(localSearch, bookmarkCategories) } - totalCategories={categories.length} + totalCategories={bookmarkCategories.length} searching={!!localSearch} /> )} @@ -178,11 +184,12 @@ const Home = (props: ComponentProps): JSX.Element => { const mapStateToProps = (state: GlobalState) => { return { + appCategories: state.app.categories, appsLoading: state.app.loading, apps: state.app.apps, - categoriesLoading: state.bookmark.loading, - categories: state.bookmark.categories, - }; -}; + bookmarkCategoriesLoading: state.bookmark.loading, + bookmarkCategories: state.bookmark.categories, + } +} -export default connect(mapStateToProps, { getApps, getCategories })(Home); +export default connect(mapStateToProps, { getApps, getAppCategories, getBookmarkCategories })(Home); diff --git a/client/src/components/Settings/OtherSettings/OtherSettings.tsx b/client/src/components/Settings/OtherSettings/OtherSettings.tsx index c3525f8..7d2e83b 100644 --- a/client/src/components/Settings/OtherSettings/OtherSettings.tsx +++ b/client/src/components/Settings/OtherSettings/OtherSettings.tsx @@ -1,34 +1,25 @@ -import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; - -// Redux +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; import { connect } from 'react-redux'; + +import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces'; import { createNotification, - updateConfig, + sortAppCategories, sortApps, - sortCategories, + sortBookmarkCategories, + 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 SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline'; - -// Utils import { searchConfig } from '../../../utility'; +import Button from '../../UI/Buttons/Button/Button'; +import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; +import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline'; interface ComponentProps { createNotification: (notification: NewNotification) => void; updateConfig: (formData: SettingsForm) => void; + sortAppCategories: () => void; sortApps: () => void; - sortCategories: () => void; + sortBookmarkCategories: () => void; loading: boolean; } @@ -79,9 +70,10 @@ const OtherSettings = (props: ComponentProps): JSX.Element => { // Update local page title document.title = formData.customTitle; - // Sort apps and categories with new settings + // Apply new sort settings + props.sortAppCategories(); props.sortApps(); - props.sortCategories(); + props.sortBookmarkCategories(); }; // Input handler @@ -293,7 +285,8 @@ const actions = { createNotification, updateConfig, sortApps, - sortCategories, + sortAppCategories, + sortBookmarkCategories }; export default connect(mapStateToProps, actions)(OtherSettings); diff --git a/client/src/interfaces/App.ts b/client/src/interfaces/App.ts index e4314a5..16b1443 100644 --- a/client/src/interfaces/App.ts +++ b/client/src/interfaces/App.ts @@ -3,6 +3,7 @@ import { Model } from '.'; export interface App extends Model { name: string; url: string; + categoryId: number; icon: string; isPinned: boolean; orderId: number; @@ -11,5 +12,6 @@ export interface App extends Model { export interface NewApp { name: string; url: string; + categoryId: number; icon: string; } \ No newline at end of file diff --git a/client/src/interfaces/Category.ts b/client/src/interfaces/Category.ts index 0f9f8f9..0f4f734 100644 --- a/client/src/interfaces/Category.ts +++ b/client/src/interfaces/Category.ts @@ -1,7 +1,8 @@ -import { Model, Bookmark } from '.'; +import { Bookmark, Model } from '.'; export interface Category extends Model { name: string; + type: string; isPinned: boolean; orderId: number; bookmarks: Bookmark[]; @@ -9,4 +10,5 @@ export interface Category extends Model { export interface NewCategory { name: string; + type: string; } \ No newline at end of file diff --git a/client/src/store/actions/actionTypes.ts b/client/src/store/actions/actionTypes.ts index c670b2f..19a23be 100644 --- a/client/src/store/actions/actionTypes.ts +++ b/client/src/store/actions/actionTypes.ts @@ -1,44 +1,50 @@ import { - // Theme - SetThemeAction, - // Apps - GetAppsAction, - PinAppAction, AddAppAction, - DeleteAppAction, - UpdateAppAction, - ReorderAppsAction, - SortAppsAction, - // Categories - GetCategoriesAction, - AddCategoryAction, - PinCategoryAction, - DeleteCategoryAction, - UpdateCategoryAction, - SortCategoriesAction, - ReorderCategoriesAction, - // Bookmarks + AddAppCategoryAction, AddBookmarkAction, - DeleteBookmarkAction, - UpdateBookmarkAction, - // Notifications - CreateNotificationAction, + AddBookmarkCategoryAction, ClearNotificationAction, - // Config + CreateNotificationAction, + DeleteAppAction, + DeleteAppCategoryAction, + DeleteBookmarkAction, + DeleteBookmarkCategoryAction, + GetAppCategoriesAction, + GetAppsAction, + GetBookmarkCategoriesAction, GetConfigAction, + PinAppAction, + PinAppCategoryAction, + PinBookmarkCategoryAction, + ReorderAppCategoriesAction, + ReorderAppsAction, + ReorderBookmarkCategoriesAction, + SetThemeAction, + SortAppCategoriesAction, + SortAppsAction, + SortBookmarkCategoriesAction, + UpdateAppAction, + UpdateAppCategoryAction, + UpdateBookmarkAction, + UpdateBookmarkCategoryAction, UpdateConfigAction, } from './'; -import { - AddQueryAction, - DeleteQueryAction, - FetchQueriesAction, - UpdateQueryAction, -} from './config'; +import { AddQueryAction, DeleteQueryAction, FetchQueriesAction, UpdateQueryAction } from './config'; export enum ActionTypes { // Theme setTheme = 'SET_THEME', - // Apps + // App categories + getAppCategories = 'GET_APP_CATEGORIES', + getAppCategoriesSuccess = 'GET_APP_CATEGORIES_SUCCESS', + getAppCategoriesError = 'GET_APP_CATEGORIES_ERROR', + addAppCategory = 'ADD_APP_CATEGORY', + pinAppCategory = 'PIN_APP_CATEGORY', + deleteAppCategory = 'DELETE_APP_CATEGORY', + updateAppCategory = 'UPDATE_APP_CATEGORY', + sortAppCategories = 'SORT_APP_CATEGORIES', + reorderAppCategories = 'REORDER_APP_CATEGORIES', + // Apps getApps = 'GET_APPS', getAppsSuccess = 'GET_APPS_SUCCESS', getAppsError = 'GET_APPS_ERROR', @@ -49,16 +55,16 @@ export enum ActionTypes { updateApp = 'UPDATE_APP', reorderApps = 'REORDER_APPS', sortApps = 'SORT_APPS', - // Categories - getCategories = 'GET_CATEGORIES', - getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS', - getCategoriesError = 'GET_CATEGORIES_ERROR', - addCategory = 'ADD_CATEGORY', - pinCategory = 'PIN_CATEGORY', - deleteCategory = 'DELETE_CATEGORY', - updateCategory = 'UPDATE_CATEGORY', - sortCategories = 'SORT_CATEGORIES', - reorderCategories = 'REORDER_CATEGORIES', + // Bookmark categories + getBookmarkCategories = 'GET_BOOKMARK_CATEGORIES', + getBookmarkCategoriesSuccess = 'GET_BOOKMARK_CATEGORIES_SUCCESS', + getBookmarkCategoriesError = 'GET_BOOKMARK_CATEGORIES_ERROR', + addBookmarkCategory = 'ADD_BOOKMARK_CATEGORY', + pinBookmarkCategory = 'PIN_BOOKMARK_CATEGORY', + deleteBookmarkCategory = 'DELETE_BOOKMARK_CATEGORY', + updateBookmarkCategory = 'UPDATE_BOOKMARK_CATEGORY', + sortBookmarkCategories = 'SORT_BOOKMARK_CATEGORIES', + reorderBookmarkCategories = 'REORDER_BOOKMARK_CATEGORIES', // Bookmarks addBookmark = 'ADD_BOOKMARK', deleteBookmark = 'DELETE_BOOKMARK', @@ -77,7 +83,15 @@ export enum ActionTypes { export type Action = // Theme - | SetThemeAction + SetThemeAction + // App categories + | GetAppCategoriesAction + | AddAppCategoryAction + | PinAppCategoryAction + | DeleteAppCategoryAction + | UpdateAppCategoryAction + | SortAppCategoriesAction + | ReorderAppCategoriesAction // Apps | GetAppsAction | PinAppAction @@ -86,14 +100,14 @@ export type Action = | UpdateAppAction | ReorderAppsAction | SortAppsAction - // Categories - | GetCategoriesAction - | AddCategoryAction - | PinCategoryAction - | DeleteCategoryAction - | UpdateCategoryAction - | SortCategoriesAction - | ReorderCategoriesAction + // Bookmark categories + | GetBookmarkCategoriesAction + | AddBookmarkCategoryAction + | PinBookmarkCategoryAction + | DeleteBookmarkCategoryAction + | UpdateBookmarkCategoryAction + | SortBookmarkCategoriesAction + | ReorderBookmarkCategoriesAction // Bookmarks | AddBookmarkAction | DeleteBookmarkAction diff --git a/client/src/store/actions/app.ts b/client/src/store/actions/app.ts index 3a8e7d5..6b1ad31 100644 --- a/client/src/store/actions/app.ts +++ b/client/src/store/actions/app.ts @@ -1,32 +1,96 @@ import axios from 'axios'; import { Dispatch } from 'redux'; + +import { ApiResponse, App, Category, Config, NewApp, NewCategory } from '../../interfaces'; import { ActionTypes } from './actionTypes'; -import { App, ApiResponse, NewApp, Config } from '../../interfaces'; import { CreateNotificationAction } from './notification'; export interface GetAppsAction { - type: ActionTypes.getApps | ActionTypes.getAppsSuccess | ActionTypes.getAppsError; + type: + | ActionTypes.getApps + | ActionTypes.getAppsSuccess + | ActionTypes.getAppsError; payload: T; } export const getApps = () => async (dispatch: Dispatch) => { dispatch>({ type: ActionTypes.getApps, - payload: undefined + payload: undefined, }); try { - const res = await axios.get>('/api/apps'); + const res = await axios.get>("/api/apps"); dispatch>({ type: ActionTypes.getAppsSuccess, - payload: res.data.data - }) + payload: res.data.data, + }); } catch (err) { console.log(err); } +}; + +export interface GetAppCategoriesAction { + type: + | ActionTypes.getAppCategories + | ActionTypes.getAppCategoriesSuccess + | ActionTypes.getAppCategoriesSuccess; + payload: T; } +export const getAppCategories = () => async (dispatch: Dispatch) => { + dispatch>({ + type: ActionTypes.getAppCategories, + payload: undefined, + }); + + try { + const res = await axios.get>("/api/categories"); + + dispatch>({ + type: ActionTypes.getAppCategoriesSuccess, + payload: res.data.data.filter( + (category: Category) => category.type === "apps" + ), + }); + } catch (err) { + console.log(err); + } +}; + +export interface AddAppCategoryAction { + type: ActionTypes.addAppCategory; + payload: Category; +} + +export const addAppCategory = + (formData: NewCategory) => async (dispatch: Dispatch) => { + try { + const res = await axios.post>( + "/api/categories", + formData + ); + + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: "Success", + message: `Category ${formData.name} created`, + }, + }); + + dispatch({ + type: ActionTypes.addAppCategory, + payload: res.data.data, + }); + + dispatch(sortAppCategories()); + } catch (err) { + console.log(err); + } + }; + export interface PinAppAction { type: ActionTypes.pinApp; payload: App; @@ -35,26 +99,30 @@ export interface PinAppAction { export const pinApp = (app: App) => async (dispatch: Dispatch) => { try { const { id, isPinned, name } = app; - const res = await axios.put>(`/api/apps/${id}`, { isPinned: !isPinned }); + const res = await axios.put>(`/api/apps/${id}`, { + isPinned: !isPinned, + }); - const status = isPinned ? 'unpinned from Homescreen' : 'pinned to Homescreen'; + const status = isPinned + ? "unpinned from Homescreen" + : "pinned to Homescreen"; dispatch({ type: ActionTypes.createNotification, payload: { - title: 'Success', - message: `App ${name} ${status}` - } - }) + title: "Success", + message: `App ${name} ${status}`, + }, + }); dispatch({ type: ActionTypes.pinApp, - payload: res.data.data - }) + payload: res.data.data, + }); } catch (err) { console.log(err); } -} +}; export interface AddAppAction { type: ActionTypes.addAppSuccess; @@ -63,31 +131,133 @@ export interface AddAppAction { export const addApp = (formData: NewApp | FormData) => async (dispatch: Dispatch) => { try { - const res = await axios.post>('/api/apps', formData); + const res = await axios.post>("/api/apps", formData); dispatch({ type: ActionTypes.createNotification, payload: { - title: 'Success', - message: `App added` - } - }) + title: "Success", + message: `App ${res.data.data.name} added`, + }, + }); await dispatch({ type: ActionTypes.addAppSuccess, - payload: res.data.data - }) + payload: res.data.data, + }); // Sort apps - dispatch(sortApps()) + dispatch(sortApps()); } catch (err) { console.log(err); } +}; + +/** + * PIN CATEGORY + */ +export interface PinAppCategoryAction { + type: ActionTypes.pinAppCategory; + payload: Category; } +export const pinAppCategory = + (category: Category) => async (dispatch: Dispatch) => { + try { + const { id, isPinned, name } = category; + const res = await axios.put>( + `/api/categories/${id}`, + { isPinned: !isPinned } + ); + + const status = isPinned + ? "unpinned from Homescreen" + : "pinned to Homescreen"; + + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: "Success", + message: `Category ${name} ${status}`, + }, + }); + + dispatch({ + type: ActionTypes.pinAppCategory, + payload: res.data.data, + }); + } catch (err) { + console.log(err); + } + }; + +/** + * DELETE CATEGORY + */ +export interface DeleteAppCategoryAction { + type: ActionTypes.deleteAppCategory; + payload: number; +} + +export const deleteAppCategory = (id: number) => async (dispatch: Dispatch) => { + try { + await axios.delete>(`/api/categories/${id}`); + + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: "Success", + message: `Category deleted`, + }, + }); + + dispatch({ + type: ActionTypes.deleteAppCategory, + payload: id, + }); + } catch (err) { + console.log(err); + } +}; + +/** + * UPDATE CATEGORY + */ +export interface UpdateAppCategoryAction { + type: ActionTypes.updateAppCategory; + payload: Category; +} + +export const updateAppCategory = + (id: number, formData: NewCategory) => async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/categories/${id}`, + formData + ); + + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: "Success", + message: `Category ${formData.name} updated`, + }, + }); + + dispatch({ + type: ActionTypes.updateAppCategory, + payload: res.data.data, + }); + + dispatch(sortAppCategories()); + } catch (err) { + console.log(err); + } + }; + export interface DeleteAppAction { - type: ActionTypes.deleteApp, - payload: number + type: ActionTypes.deleteApp; + payload: number; } export const deleteApp = (id: number) => async (dispatch: Dispatch) => { @@ -97,80 +267,86 @@ export const deleteApp = (id: number) => async (dispatch: Dispatch) => { dispatch({ type: ActionTypes.createNotification, payload: { - title: 'Success', - message: 'App deleted' - } - }) + title: "Success", + message: "App deleted", + }, + }); dispatch({ type: ActionTypes.deleteApp, - payload: id - }) + payload: id, + }); } catch (err) { console.log(err); } -} +}; export interface UpdateAppAction { type: ActionTypes.updateApp; payload: App; } -export const updateApp = (id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => { - try { - const res = await axios.put>(`/api/apps/${id}`, formData); +export const updateApp = (id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => { + try { + const res = await axios.put>( + `/api/apps/${id}`, + formData + ); - dispatch({ - type: ActionTypes.createNotification, - payload: { - title: 'Success', - message: `App updated` - } - }) + dispatch({ + type: ActionTypes.createNotification, + payload: { + title: "Success", + message: `App ${res.data.data.name} updated`, + }, + }); - await dispatch({ - type: ActionTypes.updateApp, - payload: res.data.data - }) + await dispatch({ + type: ActionTypes.updateApp, + payload: res.data.data, + }); - // Sort apps - dispatch(sortApps()) - } catch (err) { - console.log(err); - } -} + // Sort apps + dispatch(sortApps()); + } catch (err) { + console.log(err); + } + }; export interface ReorderAppsAction { type: ActionTypes.reorderApps; - payload: App[] + payload: App[]; } -interface ReorderQuery { +interface ReorderAppsQuery { apps: { id: number; orderId: number; - }[] + }[]; } export const reorderApps = (apps: App[]) => async (dispatch: Dispatch) => { try { - const updateQuery: ReorderQuery = { apps: [] } + const updateQuery: ReorderAppsQuery = { apps: [] }; - apps.forEach((app, index) => updateQuery.apps.push({ - id: app.id, - orderId: index + 1 - })) + apps.forEach((app, index) => { + updateQuery.apps.push({ + id: app.id, + orderId: index + 1, + }); + app.orderId = index + 1; + }); - await axios.put>('/api/apps/0/reorder', updateQuery); + await axios.put>("/api/apps/0/reorder", updateQuery); dispatch({ type: ActionTypes.reorderApps, - payload: apps - }) + payload: apps, + }); } catch (err) { console.log(err); } -} +}; export interface SortAppsAction { type: ActionTypes.sortApps; @@ -179,13 +355,75 @@ export interface SortAppsAction { export const sortApps = () => async (dispatch: Dispatch) => { try { - const res = await axios.get>('/api/config/useOrdering'); + const res = await axios.get>("/api/config/useOrdering"); dispatch({ type: ActionTypes.sortApps, - payload: res.data.data.value - }) + payload: res.data.data.value, + }); } catch (err) { console.log(err); } -} \ No newline at end of file +}; + +/** + * SORT CATEGORIES + */ +export interface SortAppCategoriesAction { + type: ActionTypes.sortAppCategories; + payload: string; +} + +export const sortAppCategories = () => async (dispatch: Dispatch) => { + try { + const res = await axios.get>("/api/config/useOrdering"); + + dispatch({ + type: ActionTypes.sortAppCategories, + payload: res.data.data.value, + }); + } catch (err) { + console.log(err); + } +}; + +/** + * REORDER CATEGORIES + */ +export interface ReorderAppCategoriesAction { + type: ActionTypes.reorderAppCategories; + payload: Category[]; +} + +interface ReorderCategoriesQuery { + categories: { + id: number; + orderId: number; + }[]; +} + +export const reorderAppCategories = + (categories: Category[]) => async (dispatch: Dispatch) => { + try { + const updateQuery: ReorderCategoriesQuery = { categories: [] }; + + categories.forEach((category, index) => + updateQuery.categories.push({ + id: category.id, + orderId: index + 1, + }) + ); + + await axios.put>( + "/api/categories/0/reorder", + updateQuery + ); + + dispatch({ + type: ActionTypes.reorderAppCategories, + payload: categories, + }); + } catch (err) { + console.log(err); + } + }; diff --git a/client/src/store/actions/bookmark.ts b/client/src/store/actions/bookmark.ts index b4b5831..028a97d 100644 --- a/client/src/store/actions/bookmark.ts +++ b/client/src/store/actions/bookmark.ts @@ -1,29 +1,30 @@ import axios from 'axios'; import { Dispatch } from 'redux'; + +import { ApiResponse, Bookmark, Category, Config, NewBookmark, NewCategory } from '../../interfaces'; import { ActionTypes } from './actionTypes'; -import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark, Config } from '../../interfaces'; import { CreateNotificationAction } from './notification'; /** * GET CATEGORIES */ -export interface GetCategoriesAction { - type: ActionTypes.getCategories | ActionTypes.getCategoriesSuccess | ActionTypes.getCategoriesError; +export interface GetBookmarkCategoriesAction { + type: ActionTypes.getBookmarkCategories | ActionTypes.getBookmarkCategoriesSuccess | ActionTypes.getBookmarkCategoriesError; payload: T; } -export const getCategories = () => async (dispatch: Dispatch) => { - dispatch>({ - type: ActionTypes.getCategories, +export const getBookmarkCategories = () => async (dispatch: Dispatch) => { + dispatch>({ + type: ActionTypes.getBookmarkCategories, payload: undefined }) try { const res = await axios.get>('/api/categories'); - dispatch>({ - type: ActionTypes.getCategoriesSuccess, - payload: res.data.data + dispatch>({ + type: ActionTypes.getBookmarkCategoriesSuccess, + payload: res.data.data.filter((category: Category) => category.type === 'bookmarks'), }) } catch (err) { console.log(err); @@ -33,12 +34,12 @@ export const getCategories = () => async (dispatch: Dispatch) => { /** * ADD CATEGORY */ -export interface AddCategoryAction { - type: ActionTypes.addCategory, +export interface AddBookmarkCategoryAction { + type: ActionTypes.addBookmarkCategory, payload: Category } -export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch) => { +export const addBookmarkCategory = (formData: NewCategory) => async (dispatch: Dispatch) => { try { const res = await axios.post>('/api/categories', formData); @@ -50,12 +51,12 @@ export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch) } }) - dispatch({ - type: ActionTypes.addCategory, + dispatch({ + type: ActionTypes.addBookmarkCategory, payload: res.data.data }) - dispatch(sortCategories()); + dispatch(sortBookmarkCategories()); } catch (err) { console.log(err); } @@ -93,12 +94,12 @@ export const addBookmark = (formData: NewBookmark | FormData) => async (dispatch /** * PIN CATEGORY */ -export interface PinCategoryAction { - type: ActionTypes.pinCategory, +export interface PinBookmarkCategoryAction { + type: ActionTypes.pinBookmarkCategory, payload: Category } -export const pinCategory = (category: Category) => async (dispatch: Dispatch) => { +export const pinBookmarkCategory = (category: Category) => async (dispatch: Dispatch) => { try { const { id, isPinned, name } = category; const res = await axios.put>(`/api/categories/${id}`, { isPinned: !isPinned }); @@ -113,8 +114,8 @@ export const pinCategory = (category: Category) => async (dispatch: Dispatch) => } }) - dispatch({ - type: ActionTypes.pinCategory, + dispatch({ + type: ActionTypes.pinBookmarkCategory, payload: res.data.data }) } catch (err) { @@ -125,12 +126,12 @@ export const pinCategory = (category: Category) => async (dispatch: Dispatch) => /** * DELETE CATEGORY */ -export interface DeleteCategoryAction { - type: ActionTypes.deleteCategory, +export interface DeleteBookmarkCategoryAction { + type: ActionTypes.deleteBookmarkCategory, payload: number } -export const deleteCategory = (id: number) => async (dispatch: Dispatch) => { +export const deleteBookmarkCategory = (id: number) => async (dispatch: Dispatch) => { try { await axios.delete>(`/api/categories/${id}`); @@ -142,8 +143,8 @@ export const deleteCategory = (id: number) => async (dispatch: Dispatch) => { } }) - dispatch({ - type: ActionTypes.deleteCategory, + dispatch({ + type: ActionTypes.deleteBookmarkCategory, payload: id }) } catch (err) { @@ -154,12 +155,12 @@ export const deleteCategory = (id: number) => async (dispatch: Dispatch) => { /** * UPDATE CATEGORY */ -export interface UpdateCategoryAction { - type: ActionTypes.updateCategory, +export interface UpdateBookmarkCategoryAction { + type: ActionTypes.updateBookmarkCategory, payload: Category } -export const updateCategory = (id: number, formData: NewCategory) => async (dispatch: Dispatch) => { +export const updateBookmarkCategory = (id: number, formData: NewCategory) => async (dispatch: Dispatch) => { try { const res = await axios.put>(`/api/categories/${id}`, formData); @@ -171,12 +172,12 @@ export const updateCategory = (id: number, formData: NewCategory) => async (disp } }) - dispatch({ - type: ActionTypes.updateCategory, + dispatch({ + type: ActionTypes.updateBookmarkCategory, payload: res.data.data }) - dispatch(sortCategories()); + dispatch(sortBookmarkCategories()); } catch (err) { console.log(err); } @@ -277,17 +278,17 @@ export const updateBookmark = ( /** * SORT CATEGORIES */ -export interface SortCategoriesAction { - type: ActionTypes.sortCategories; +export interface SortBookmarkCategoriesAction { + type: ActionTypes.sortBookmarkCategories; payload: string; } -export const sortCategories = () => async (dispatch: Dispatch) => { +export const sortBookmarkCategories = () => async (dispatch: Dispatch) => { try { const res = await axios.get>('/api/config/useOrdering'); - dispatch({ - type: ActionTypes.sortCategories, + dispatch({ + type: ActionTypes.sortBookmarkCategories, payload: res.data.data.value }) } catch (err) { @@ -298,8 +299,8 @@ export const sortCategories = () => async (dispatch: Dispatch) => { /** * REORDER CATEGORIES */ -export interface ReorderCategoriesAction { - type: ActionTypes.reorderCategories; +export interface ReorderBookmarkCategoriesAction { + type: ActionTypes.reorderBookmarkCategories; payload: Category[]; } @@ -310,7 +311,7 @@ interface ReorderQuery { }[] } -export const reorderCategories = (categories: Category[]) => async (dispatch: Dispatch) => { +export const reorderBookmarkCategories = (categories: Category[]) => async (dispatch: Dispatch) => { try { const updateQuery: ReorderQuery = { categories: [] } @@ -321,8 +322,8 @@ export const reorderCategories = (categories: Category[]) => async (dispatch: Di await axios.put>('/api/categories/0/reorder', updateQuery); - dispatch({ - type: ActionTypes.reorderCategories, + dispatch({ + type: ActionTypes.reorderBookmarkCategories, payload: categories }) } catch (err) { diff --git a/client/src/store/reducers/app.ts b/client/src/store/reducers/app.ts index 0935819..71164e0 100644 --- a/client/src/store/reducers/app.ts +++ b/client/src/store/reducers/app.ts @@ -1,72 +1,152 @@ -import { ActionTypes, Action } from '../actions'; -import { App } from '../../interfaces/App'; +import { App, Category } from '../../interfaces'; import { sortData } from '../../utility'; +import { Action, ActionTypes } from '../actions'; export interface State { loading: boolean; - apps: App[]; errors: string | undefined; + categories: Category[]; + apps: App[]; } const initialState: State = { loading: true, + errors: undefined, + categories: [], apps: [], - errors: undefined -} +}; const getApps = (state: State, action: Action): State => { return { ...state, loading: true, - errors: undefined - } -} + errors: undefined, + }; +}; const getAppsSuccess = (state: State, action: Action): State => { return { ...state, loading: false, - apps: action.payload - } -} + apps: action.payload, + }; +}; const getAppsError = (state: State, action: Action): State => { return { ...state, loading: false, - errors: action.payload + errors: action.payload, + }; +}; + +const getCategories = (state: State, action: Action): State => { + return { + ...state, + loading: true, + errors: undefined, + }; +}; + +const getCategoriesSuccess = (state: State, action: Action): State => { + return { + ...state, + loading: false, + categories: action.payload, + }; +}; + +const addCategory = (state: State, action: Action): State => { + return { + ...state, + categories: [ + ...state.categories, + { + ...action.payload, + type: "apps", + apps: [], + }, + ], + }; +}; + +const pinCategory = (state: State, action: Action): State => { + const tmpCategories = [...state.categories]; + const changedCategory = tmpCategories.find( + (category: Category) => category.id === action.payload.id + ); + + if (changedCategory) { + changedCategory.isPinned = action.payload.isPinned; } -} + + return { + ...state, + categories: tmpCategories, + }; +}; const pinApp = (state: State, action: Action): State => { const tmpApps = [...state.apps]; const changedApp = tmpApps.find((app: App) => app.id === action.payload.id); - + if (changedApp) { changedApp.isPinned = action.payload.isPinned; } - + return { ...state, - apps: tmpApps - } -} + apps: tmpApps, + }; +}; const addAppSuccess = (state: State, action: Action): State => { return { ...state, - apps: [...state.apps, action.payload] - } -} + apps: [...state.apps, action.payload], + }; +}; -const deleteApp = (state: State, action: Action): State => { - const tmpApps = [...state.apps].filter((app: App) => app.id !== action.payload); +const deleteCategory = (state: State, action: Action): State => { + const categoryIndex = state.categories.findIndex( + (category: Category) => category.id === action.payload + ); return { ...state, - apps: tmpApps + categories: [ + ...state.categories.slice(0, categoryIndex), + ...state.categories.slice(categoryIndex + 1), + ], + }; +}; + +const updateCategory = (state: State, action: Action): State => { + const tmpCategories = [...state.categories]; + const categoryInUpdate = tmpCategories.find( + (category: Category) => category.id === action.payload.id + ); + + if (categoryInUpdate) { + categoryInUpdate.name = action.payload.name; } -} + + return { + ...state, + categories: tmpCategories, + }; +}; + +const deleteApp = (state: State, action: Action): State => { + const tmpApps = [...state.apps].filter( + (app: App) => app.id !== action.payload + ); + + return { + ...state, + apps: tmpApps, + }; +}; const updateApp = (state: State, action: Action): State => { const tmpApps = [...state.apps]; @@ -76,43 +156,96 @@ const updateApp = (state: State, action: Action): State => { appInUpdate.name = action.payload.name; appInUpdate.url = action.payload.url; appInUpdate.icon = action.payload.icon; + appInUpdate.categoryId = action.payload.categoryId; } return { ...state, - apps: tmpApps - } -} + apps: tmpApps, + }; +}; + +const sortAppCategories = (state: State, action: Action): State => { + const sortedCategories = sortData(state.categories, action.payload); + + return { + ...state, + categories: sortedCategories, + }; +}; + +const reorderCategories = (state: State, action: Action): State => { + return { + ...state, + categories: action.payload, + }; +}; const reorderApps = (state: State, action: Action): State => { return { ...state, - apps: action.payload - } -} + apps: action.payload, + }; +}; const sortApps = (state: State, action: Action): State => { + // const tmpCategories = [...state.categories]; + + // tmpCategories.forEach((category: Category) => { + // category.apps = sortData(category.apps, action.payload); + // }); + + // return { + // ...state, + // categories: tmpCategories, + // }; const sortedApps = sortData(state.apps, action.payload); return { ...state, - apps: sortedApps - } -} + apps: sortedApps, + }; +}; const appReducer = (state = initialState, action: Action) => { switch (action.type) { - case ActionTypes.getApps: return getApps(state, action); - case ActionTypes.getAppsSuccess: return getAppsSuccess(state, action); - case ActionTypes.getAppsError: return getAppsError(state, action); - case ActionTypes.pinApp: return pinApp(state, action); - case ActionTypes.addAppSuccess: return addAppSuccess(state, action); - case ActionTypes.deleteApp: return deleteApp(state, action); - case ActionTypes.updateApp: return updateApp(state, action); - case ActionTypes.reorderApps: return reorderApps(state, action); - case ActionTypes.sortApps: return sortApps(state, action); - default: return state; + case ActionTypes.getAppCategories: + return getCategories(state, action); + case ActionTypes.getAppCategoriesSuccess: + return getCategoriesSuccess(state, action); + case ActionTypes.getApps: + return getApps(state, action); + case ActionTypes.getAppsSuccess: + return getAppsSuccess(state, action); + case ActionTypes.getAppsError: + return getAppsError(state, action); + case ActionTypes.addAppCategory: + return addCategory(state, action); + case ActionTypes.addAppSuccess: + return addAppSuccess(state, action); + case ActionTypes.pinAppCategory: + return pinCategory(state, action); + case ActionTypes.pinApp: + return pinApp(state, action); + case ActionTypes.deleteAppCategory: + return deleteCategory(state, action); + case ActionTypes.updateAppCategory: + return updateCategory(state, action); + case ActionTypes.deleteApp: + return deleteApp(state, action); + case ActionTypes.updateApp: + return updateApp(state, action); + case ActionTypes.sortAppCategories: + return sortAppCategories(state, action); + case ActionTypes.reorderAppCategories: + return reorderCategories(state, action); + case ActionTypes.sortApps: + return sortApps(state, action); + case ActionTypes.reorderApps: + return reorderApps(state, action); + default: + return state; } -} +}; -export default appReducer; \ No newline at end of file +export default appReducer; diff --git a/client/src/store/reducers/bookmark.ts b/client/src/store/reducers/bookmark.ts index a554d6e..704df2b 100644 --- a/client/src/store/reducers/bookmark.ts +++ b/client/src/store/reducers/bookmark.ts @@ -1,6 +1,6 @@ -import { ActionTypes, Action } from '../actions'; -import { Category, Bookmark } from '../../interfaces'; +import { Bookmark, Category } from '../../interfaces'; import { sortData } from '../../utility'; +import { Action, ActionTypes } from '../actions'; export interface State { loading: boolean; @@ -37,6 +37,7 @@ const addCategory = (state: State, action: Action): State => { ...state.categories, { ...action.payload, + type: 'bookmarks', bookmarks: [] } ] @@ -142,7 +143,7 @@ const updateBookmark = (state: State, action: Action): State => { } } -const sortCategories = (state: State, action: Action): State => { +const sortBookmarkCategories = (state: State, action: Action): State => { const sortedCategories = sortData(state.categories, action.payload); return { @@ -160,17 +161,17 @@ const reorderCategories = (state: State, action: Action): State => { const bookmarkReducer = (state = initialState, action: Action) => { switch (action.type) { - case ActionTypes.getCategories: return getCategories(state, action); - case ActionTypes.getCategoriesSuccess: return getCategoriesSuccess(state, action); - case ActionTypes.addCategory: return addCategory(state, action); + case ActionTypes.getBookmarkCategories: return getCategories(state, action); + case ActionTypes.getBookmarkCategoriesSuccess: return getCategoriesSuccess(state, action); + case ActionTypes.addBookmarkCategory: return addCategory(state, action); case ActionTypes.addBookmark: return addBookmark(state, action); - case ActionTypes.pinCategory: return pinCategory(state, action); - case ActionTypes.deleteCategory: return deleteCategory(state, action); - case ActionTypes.updateCategory: return updateCategory(state, action); + case ActionTypes.pinBookmarkCategory: return pinCategory(state, action); + case ActionTypes.deleteBookmarkCategory: return deleteCategory(state, action); + case ActionTypes.updateBookmarkCategory: return updateCategory(state, action); case ActionTypes.deleteBookmark: return deleteBookmark(state, action); case ActionTypes.updateBookmark: return updateBookmark(state, action); - case ActionTypes.sortCategories: return sortCategories(state, action); - case ActionTypes.reorderCategories: return reorderCategories(state, action); + case ActionTypes.sortBookmarkCategories: return sortBookmarkCategories(state, action); + case ActionTypes.reorderBookmarkCategories: return reorderCategories(state, action); default: return state; } } diff --git a/controllers/category.js b/controllers/category.js index 0f1af58..39d208c 100644 --- a/controllers/category.js +++ b/controllers/category.js @@ -42,12 +42,16 @@ exports.getCategories = asyncWrapper(async (req, res, next) => { where: { key: 'useOrdering' }, }); - const orderType = useOrdering ? useOrdering.value : 'createdAt'; + const orderType = useOrdering ? useOrdering.value : "createdAt"; let categories; - if (orderType == 'name') { + if (orderType == "name") { categories = await Category.findAll({ include: [ + { + model: App, + as: 'apps', + }, { model: Bookmark, as: 'bookmarks', @@ -58,6 +62,10 @@ exports.getCategories = asyncWrapper(async (req, res, next) => { } else { categories = await Category.findAll({ include: [ + { + model: App, + as: 'apps', + }, { model: Bookmark, as: 'bookmarks', @@ -80,6 +88,10 @@ exports.getCategory = asyncWrapper(async (req, res, next) => { const category = await Category.findOne({ where: { id: req.params.id }, include: [ + { + model: App, + as: 'apps', + }, { model: Bookmark, as: 'bookmarks', @@ -134,6 +146,10 @@ exports.deleteCategory = asyncWrapper(async (req, res, next) => { const category = await Category.findOne({ where: { id: req.params.id }, include: [ + { + model: App, + as: 'apps', + }, { model: Bookmark, as: 'bookmarks', @@ -150,6 +166,12 @@ exports.deleteCategory = asyncWrapper(async (req, res, next) => { ); } + category.apps.forEach(async (app) => { + await App.destroy({ + where: { id: app.id }, + }); + }); + category.bookmarks.forEach(async (bookmark) => { await Bookmark.destroy({ where: { id: bookmark.id }, diff --git a/models/App.js b/models/App.js index f521955..03f5dc5 100644 --- a/models/App.js +++ b/models/App.js @@ -10,6 +10,10 @@ const App = sequelize.define('App', { type: DataTypes.STRING, allowNull: false }, + categoryId: { + type: DataTypes.INTEGER, + allowNull: false + }, icon: { type: DataTypes.STRING, allowNull: false, diff --git a/models/Category.js b/models/Category.js index 9c9eda6..75bda95 100644 --- a/models/Category.js +++ b/models/Category.js @@ -6,6 +6,10 @@ const Category = sequelize.define('Category', { type: DataTypes.STRING, allowNull: false }, + type: { + type: DataTypes.STRING, + allowNull: false + }, isPinned: { type: DataTypes.BOOLEAN, defaultValue: false diff --git a/models/associateModels.js b/models/associateModels.js index d1b86c1..b3375cc 100644 --- a/models/associateModels.js +++ b/models/associateModels.js @@ -1,7 +1,17 @@ const Category = require('./Category'); +const App = require('./App'); const Bookmark = require('./Bookmark'); const associateModels = () => { + + // Category <> App + Category.hasMany(App, { + as: 'apps', + foreignKey: 'categoryId' + }); + App.belongsTo(Category, { foreignKey: 'categoryId' }); + + // Category <> Bookmark Category.hasMany(Bookmark, { foreignKey: 'categoryId', as: 'bookmarks'