diff --git a/.env b/.env index 9f1bd80..db3637d 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ PORT=5005 NODE_ENV=development -VERSION=2.0.1 +VERSION=2.1.0 PASSWORD=flame_password SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8543534..329b2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +### v2.1.0 (2021-11-26) +- Added option to set custom order for bookmarks ([#43](https://github.com/pawelmalak/flame/issues/43)) and ([#187](https://github.com/pawelmalak/flame/issues/187)) +- Added support for .ico files for custom icons ([#209](https://github.com/pawelmalak/flame/issues/209)) +- Empty apps and categories sections will now be hidden from guests ([#210](https://github.com/pawelmalak/flame/issues/210)) +- Fixed bug with fahrenheit degrees being displayed as float ([#221](https://github.com/pawelmalak/flame/issues/221)) +- Fixed bug with alphabetical order not working for bookmarks until the page was refreshed ([#224](https://github.com/pawelmalak/flame/issues/224)) +- Added option to change visibilty of apps, categories and bookmarks directly from table view +- Password input will now autofocus when visiting /settings/app + ### v2.0.1 (2021-11-19) - Added option to display humidity in the weather widget ([#136](https://github.com/pawelmalak/flame/issues/136)) - Added option to set default theme for all new users ([#165](https://github.com/pawelmalak/flame/issues/165)) diff --git a/client/.env b/client/.env index aac1ed1..d6fe0e5 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=2.0.1 \ No newline at end of file +REACT_APP_VERSION=2.1.0 \ No newline at end of file diff --git a/client/src/components/Actions/TableActions.module.css b/client/src/components/Actions/TableActions.module.css new file mode 100644 index 0000000..69028a9 --- /dev/null +++ b/client/src/components/Actions/TableActions.module.css @@ -0,0 +1,12 @@ +.TableActions { + display: flex; + align-items: center; +} + +.TableAction { + width: 22px; +} + +.TableAction:hover { + cursor: pointer; +} diff --git a/client/src/components/Actions/TableActions.tsx b/client/src/components/Actions/TableActions.tsx new file mode 100644 index 0000000..6d9460c --- /dev/null +++ b/client/src/components/Actions/TableActions.tsx @@ -0,0 +1,81 @@ +import { Icon } from '../UI'; +import classes from './TableActions.module.css'; + +interface Entity { + id: number; + name: string; + isPinned?: boolean; + isPublic: boolean; +} + +interface Props { + entity: Entity; + deleteHandler: (id: number, name: string) => void; + updateHandler: (id: number) => void; + pinHanlder?: (id: number) => void; + changeVisibilty: (id: number) => void; + showPin?: boolean; +} + +export const TableActions = (props: Props): JSX.Element => { + const { + entity, + deleteHandler, + updateHandler, + pinHanlder, + changeVisibilty, + showPin = true, + } = props; + + const _pinHandler = pinHanlder || function () {}; + + return ( + <td className={classes.TableActions}> + {/* DELETE */} + <div + className={classes.TableAction} + onClick={() => deleteHandler(entity.id, entity.name)} + tabIndex={0} + > + <Icon icon="mdiDelete" /> + </div> + + {/* UPDATE */} + <div + className={classes.TableAction} + onClick={() => updateHandler(entity.id)} + tabIndex={0} + > + <Icon icon="mdiPencil" /> + </div> + + {/* PIN */} + {showPin && ( + <div + className={classes.TableAction} + onClick={() => _pinHandler(entity.id)} + tabIndex={0} + > + {entity.isPinned ? ( + <Icon icon="mdiPinOff" color="var(--color-accent)" /> + ) : ( + <Icon icon="mdiPin" /> + )} + </div> + )} + + {/* VISIBILITY */} + <div + className={classes.TableAction} + onClick={() => changeVisibilty(entity.id)} + tabIndex={0} + > + {entity.isPublic ? ( + <Icon icon="mdiEyeOff" color="var(--color-accent)" /> + ) : ( + <Icon icon="mdiEye" /> + )} + </div> + </td> + ); +}; diff --git a/client/src/components/Apps/AppForm/AppForm.tsx b/client/src/components/Apps/AppForm/AppForm.tsx index 0b94465..8679f82 100644 --- a/client/src/components/Apps/AppForm/AppForm.tsx +++ b/client/src/components/Apps/AppForm/AppForm.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react'; -import { useDispatch } from 'react-redux'; -import { App, NewApp } from '../../../interfaces'; +import { useDispatch, useSelector } from 'react-redux'; +import { NewApp } from '../../../interfaces'; import classes from './AppForm.module.css'; @@ -8,29 +8,34 @@ import { ModalForm, InputGroup, Button } from '../../UI'; import { inputHandler, newAppTemplate } from '../../../utility'; import { bindActionCreators } from 'redux'; import { actionCreators } from '../../../store'; +import { State } from '../../../store/reducers'; interface Props { modalHandler: () => void; - app?: App; } -export const AppForm = ({ app, modalHandler }: Props): JSX.Element => { +export const AppForm = ({ modalHandler }: Props): JSX.Element => { + const { appInUpdate } = useSelector((state: State) => state.apps); + const dispatch = useDispatch(); - const { addApp, updateApp } = bindActionCreators(actionCreators, dispatch); + const { addApp, updateApp, setEditApp } = bindActionCreators( + actionCreators, + dispatch + ); const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false); const [customIcon, setCustomIcon] = useState<File | null>(null); const [formData, setFormData] = useState<NewApp>(newAppTemplate); useEffect(() => { - if (app) { + if (appInUpdate) { setFormData({ - ...app, + ...appInUpdate, }); } else { setFormData(newAppTemplate); } - }, [app]); + }, [appInUpdate]); const inputChangeHandler = ( e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, @@ -66,7 +71,7 @@ export const AppForm = ({ app, modalHandler }: Props): JSX.Element => { return data; }; - if (!app) { + if (!appInUpdate) { if (customIcon) { const data = createFormData(); addApp(data); @@ -76,14 +81,15 @@ export const AppForm = ({ app, modalHandler }: Props): JSX.Element => { } else { if (customIcon) { const data = createFormData(); - updateApp(app.id, data); + updateApp(appInUpdate.id, data); } else { - updateApp(app.id, formData); + updateApp(appInUpdate.id, formData); modalHandler(); } } setFormData(newAppTemplate); + setEditApp(null); }; return ( @@ -154,7 +160,7 @@ export const AppForm = ({ app, modalHandler }: Props): JSX.Element => { id="icon" required onChange={(e) => fileChangeHandler(e)} - accept=".jpg,.jpeg,.png,.svg" + accept=".jpg,.jpeg,.png,.svg,.ico" /> <span onClick={() => { @@ -182,7 +188,7 @@ export const AppForm = ({ app, modalHandler }: Props): JSX.Element => { </select> </InputGroup> - {!app ? ( + {!appInUpdate ? ( <Button>Add new application</Button> ) : ( <Button>Update application</Button> diff --git a/client/src/components/Apps/AppGrid/AppGrid.module.css b/client/src/components/Apps/AppGrid/AppGrid.module.css index 7874918..daff441 100644 --- a/client/src/components/Apps/AppGrid/AppGrid.module.css +++ b/client/src/components/Apps/AppGrid/AppGrid.module.css @@ -20,21 +20,3 @@ grid-template-columns: repeat(4, 1fr); } } - -.GridMessage { - color: var(--color-primary); -} - -.GridMessage a { - color: var(--color-accent); - font-weight: 600; -} - -.AppsMessage { - color: var(--color-primary); -} - -.AppsMessage a { - color: var(--color-accent); - font-weight: 600; -} \ No newline at end of file diff --git a/client/src/components/Apps/AppGrid/AppGrid.tsx b/client/src/components/Apps/AppGrid/AppGrid.tsx index 6b02443..b4cfb4e 100644 --- a/client/src/components/Apps/AppGrid/AppGrid.tsx +++ b/client/src/components/Apps/AppGrid/AppGrid.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { App } from '../../../interfaces/App'; import { AppCard } from '../AppCard/AppCard'; +import { Message } from '../../UI'; interface Props { apps: App[]; @@ -13,36 +14,32 @@ interface Props { export const AppGrid = (props: Props): JSX.Element => { let apps: JSX.Element; - if (props.apps.length > 0) { - apps = ( - <div className={classes.AppGrid}> - {props.apps.map((app: App): JSX.Element => { - return <AppCard key={app.id} app={app} />; - })} - </div> - ); - } else { - if (props.totalApps) { - if (props.searching) { - apps = ( - <p className={classes.AppsMessage}> - No apps match your search criteria - </p> - ); - } else { - apps = ( - <p className={classes.AppsMessage}> - There are no pinned applications. You can pin them from the{' '} - <Link to="/applications">/applications</Link> menu - </p> - ); - } + if (props.searching || props.apps.length) { + if (!props.apps.length) { + apps = <Message>No apps match your search criteria</Message>; } else { apps = ( - <p className={classes.AppsMessage}> + <div className={classes.AppGrid}> + {props.apps.map((app: App): JSX.Element => { + return <AppCard key={app.id} app={app} />; + })} + </div> + ); + } + } else { + if (props.totalApps) { + apps = ( + <Message> + There are no pinned applications. You can pin them from the{' '} + <Link to="/applications">/applications</Link> menu + </Message> + ); + } else { + apps = ( + <Message> You don't have any applications. You can add a new one from{' '} <Link to="/applications">/applications</Link> menu - </p> + </Message> ); } } diff --git a/client/src/components/Apps/AppTable/AppTable.tsx b/client/src/components/Apps/AppTable/AppTable.tsx index ee82144..aa23797 100644 --- a/client/src/components/Apps/AppTable/AppTable.tsx +++ b/client/src/components/Apps/AppTable/AppTable.tsx @@ -1,4 +1,4 @@ -import { Fragment, KeyboardEvent, useState, useEffect } from 'react'; +import { Fragment, useState, useEffect } from 'react'; import { DragDropContext, Droppable, @@ -9,21 +9,20 @@ import { Link } from 'react-router-dom'; // Redux import { useDispatch, useSelector } from 'react-redux'; - -// Typescript -import { App } from '../../../interfaces'; - -// CSS -import classes from './AppTable.module.css'; - -// UI -import { Icon, Table } from '../../UI'; import { State } from '../../../store/reducers'; import { bindActionCreators } from 'redux'; import { actionCreators } from '../../../store'; +// Typescript +import { App } from '../../../interfaces'; + +// Other +import classes from './AppTable.module.css'; +import { Table } from '../../UI'; +import { TableActions } from '../../Actions/TableActions'; + interface Props { - updateAppHandler: (app: App) => void; + openFormForUpdating: (app: App) => void; } export const AppTable = (props: Props): JSX.Element => { @@ -33,49 +32,18 @@ export const AppTable = (props: Props): JSX.Element => { } = useSelector((state: State) => state); const dispatch = useDispatch(); - const { pinApp, deleteApp, reorderApps, updateConfig, createNotification } = + const { pinApp, deleteApp, reorderApps, createNotification, updateApp } = bindActionCreators(actionCreators, dispatch); const [localApps, setLocalApps] = useState<App[]>([]); - const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false); // Copy apps array useEffect(() => { setLocalApps([...apps]); }, [apps]); - // Check ordering - useEffect(() => { - const order = config.useOrdering; - - if (order === 'orderId') { - setIsCustomOrder(true); - } - }, []); - - const deleteAppHandler = (app: App): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${app.name} at ${app.url} ?` - ); - - if (proceed) { - deleteApp(app.id); - } - }; - - // Support keyboard navigation for actions - const keyboardActionHandler = ( - e: KeyboardEvent, - app: App, - handler: Function - ) => { - if (e.key === 'Enter') { - handler(app); - } - }; - const dragEndHanlder = (result: DropResult): void => { - if (!isCustomOrder) { + if (config.useOrdering !== 'orderId') { createNotification({ title: 'Error', message: 'Custom order is disabled', @@ -95,18 +63,43 @@ export const AppTable = (props: Props): JSX.Element => { reorderApps(tmpApps); }; + // Action handlers + const deleteAppHandler = (id: number, name: string) => { + const proceed = window.confirm(`Are you sure you want to delete ${name}?`); + + if (proceed) { + deleteApp(id); + } + }; + + const updateAppHandler = (id: number) => { + const app = apps.find((a) => a.id === id) as App; + props.openFormForUpdating(app); + }; + + const pinAppHandler = (id: number) => { + const app = apps.find((a) => a.id === id) as App; + pinApp(app); + }; + + const changeAppVisibiltyHandler = (id: number) => { + const app = apps.find((a) => a.id === id) as App; + updateApp(id, { ...app, isPublic: !app.isPublic }); + }; + return ( <Fragment> <div className={classes.Message}> - {isCustomOrder ? ( + {config.useOrdering === 'orderId' ? ( <p>You can drag and drop single rows to reorder application</p> ) : ( <p> - Custom order is disabled. You can change it in{' '} - <Link to="/settings/other">settings</Link> + Custom order is disabled. You can change it in the{' '} + <Link to="/settings/interface">settings</Link> </p> )} </div> + <DragDropContext onDragEnd={dragEndHanlder}> <Droppable droppableId="apps"> {(provided) => ( @@ -143,54 +136,15 @@ export const AppTable = (props: Props): JSX.Element => { <td style={{ width: '200px' }}> {app.isPublic ? 'Visible' : 'Hidden'} </td> + {!snapshot.isDragging && ( - <td className={classes.TableActions}> - <div - className={classes.TableAction} - onClick={() => deleteAppHandler(app)} - onKeyDown={(e) => - keyboardActionHandler( - e, - app, - deleteAppHandler - ) - } - tabIndex={0} - > - <Icon icon="mdiDelete" /> - </div> - <div - className={classes.TableAction} - onClick={() => props.updateAppHandler(app)} - onKeyDown={(e) => - keyboardActionHandler( - e, - app, - props.updateAppHandler - ) - } - tabIndex={0} - > - <Icon icon="mdiPencil" /> - </div> - <div - className={classes.TableAction} - onClick={() => pinApp(app)} - onKeyDown={(e) => - keyboardActionHandler(e, app, pinApp) - } - tabIndex={0} - > - {app.isPinned ? ( - <Icon - icon="mdiPinOff" - color="var(--color-accent)" - /> - ) : ( - <Icon icon="mdiPin" /> - )} - </div> - </td> + <TableActions + entity={app} + deleteHandler={deleteAppHandler} + updateHandler={updateAppHandler} + pinHanlder={pinAppHandler} + changeVisibilty={changeAppVisibiltyHandler} + /> )} </tr> ); diff --git a/client/src/components/Apps/Apps.tsx b/client/src/components/Apps/Apps.tsx index 9ccb0d4..88db874 100644 --- a/client/src/components/Apps/Apps.tsx +++ b/client/src/components/Apps/Apps.tsx @@ -19,7 +19,6 @@ import { AppForm } from './AppForm/AppForm'; import { AppTable } from './AppTable/AppTable'; // Utils -import { appTemplate } from '../../utility'; import { State } from '../../store/reducers'; import { bindActionCreators } from 'redux'; import { actionCreators } from '../../store'; @@ -29,57 +28,53 @@ interface Props { } export const Apps = (props: Props): JSX.Element => { + // Get Redux state const { apps: { apps, loading }, auth: { isAuthenticated }, } = useSelector((state: State) => state); + // Get Redux action creators const dispatch = useDispatch(); - const { getApps } = bindActionCreators(actionCreators, dispatch); - - const [modalIsOpen, setModalIsOpen] = useState(false); - const [isInEdit, setIsInEdit] = useState(false); - const [isInUpdate, setIsInUpdate] = useState(false); - const [appInUpdate, setAppInUpdate] = useState<App>(appTemplate); + const { getApps, setEditApp } = bindActionCreators(actionCreators, dispatch); + // Load apps if array is empty useEffect(() => { if (!apps.length) { getApps(); } }, []); - // observe if user is authenticated -> set default view if not + // Form + const [modalIsOpen, setModalIsOpen] = useState(false); + const [showTable, setShowTable] = useState(false); + + // Observe if user is authenticated -> set default view if not useEffect(() => { if (!isAuthenticated) { - setIsInEdit(false); + setShowTable(false); setModalIsOpen(false); } }, [isAuthenticated]); + // Form actions const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); - setIsInUpdate(false); }; const toggleEdit = (): void => { - setIsInEdit(!isInEdit); - setIsInUpdate(false); + setShowTable(!showTable); }; - const toggleUpdate = (app: App): void => { - setAppInUpdate(app); - setIsInUpdate(true); + const openFormForUpdating = (app: App): void => { + setEditApp(app); setModalIsOpen(true); }; return ( <Container> <Modal isOpen={modalIsOpen} setIsOpen={setModalIsOpen}> - {!isInUpdate ? ( - <AppForm modalHandler={toggleModal} /> - ) : ( - <AppForm modalHandler={toggleModal} app={appInUpdate} /> - )} + <AppForm modalHandler={toggleModal} /> </Modal> <Headline @@ -89,7 +84,14 @@ export const Apps = (props: Props): JSX.Element => { {isAuthenticated && ( <div className={classes.ActionsContainer}> - <ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} /> + <ActionButton + name="Add" + icon="mdiPlusBox" + handler={() => { + setEditApp(null); + toggleModal(); + }} + /> <ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} /> </div> )} @@ -97,10 +99,10 @@ export const Apps = (props: Props): JSX.Element => { <div className={classes.Apps}> {loading ? ( <Spinner /> - ) : !isInEdit ? ( + ) : !showTable ? ( <AppGrid apps={apps} searching={props.searching} /> ) : ( - <AppTable updateAppHandler={toggleUpdate} /> + <AppTable openFormForUpdating={openFormForUpdating} /> )} </div> </Container> diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css index b840a42..2fd52f0 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css @@ -10,6 +10,10 @@ text-transform: uppercase; } +.BookmarkHeader:hover { + cursor: pointer; +} + .Bookmarks { display: flex; flex-direction: column; diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx index 146bf67..8891460 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx @@ -1,28 +1,47 @@ import { Fragment } from 'react'; -import { useSelector } from 'react-redux'; +// Redux +import { useDispatch, useSelector } from 'react-redux'; import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; +// Typescript import { Bookmark, Category } from '../../../interfaces'; +// Other import classes from './BookmarkCard.module.css'; - import { Icon } from '../../UI'; - import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; interface Props { category: Category; + fromHomepage?: boolean; } export const BookmarkCard = (props: Props): JSX.Element => { + const { category, fromHomepage = false } = props; + const { config } = useSelector((state: State) => state.config); + const dispatch = useDispatch(); + const { setEditCategory } = bindActionCreators(actionCreators, dispatch); + return ( <div className={classes.BookmarkCard}> - <h3>{props.category.name}</h3> + <h3 + className={fromHomepage ? '' : classes.BookmarkHeader} + onClick={() => { + if (!fromHomepage) { + setEditCategory(category); + } + }} + > + {category.name} + </h3> + <div className={classes.Bookmarks}> - {props.category.bookmarks.map((bookmark: Bookmark) => { + {category.bookmarks.map((bookmark: Bookmark) => { const redirectUrl = urlParser(bookmark.url)[1]; let iconEl: JSX.Element = <Fragment></Fragment>; diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css index 8c0d1ab..9e89f3a 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css @@ -20,12 +20,3 @@ grid-template-columns: repeat(4, 1fr); } } - -.BookmarksMessage { - color: var(--color-primary); -} - -.BookmarksMessage a { - color: var(--color-accent); - font-weight: 600; -} \ No newline at end of file diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx index 516c3b2..7e26c32 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx @@ -5,48 +5,57 @@ import classes from './BookmarkGrid.module.css'; import { Category } from '../../../interfaces'; import { BookmarkCard } from '../BookmarkCard/BookmarkCard'; +import { Message } from '../../UI'; interface Props { categories: Category[]; totalCategories?: number; searching: boolean; + fromHomepage?: boolean; } export const BookmarkGrid = (props: Props): JSX.Element => { + const { + categories, + totalCategories, + searching, + fromHomepage = false, + } = props; + let bookmarks: JSX.Element; - if (props.categories.length) { - if (props.searching && !props.categories[0].bookmarks.length) { - bookmarks = ( - <p className={classes.BookmarksMessage}> - No bookmarks match your search criteria - </p> - ); + if (categories.length) { + if (searching && !categories[0].bookmarks.length) { + bookmarks = <Message>No bookmarks match your search criteria</Message>; } else { bookmarks = ( <div className={classes.BookmarkGrid}> - {props.categories.map( + {categories.map( (category: Category): JSX.Element => ( - <BookmarkCard category={category} key={category.id} /> + <BookmarkCard + category={category} + fromHomepage={fromHomepage} + key={category.id} + /> ) )} </div> ); } } else { - if (props.totalCategories) { + if (totalCategories) { bookmarks = ( - <p className={classes.BookmarksMessage}> + <Message> There are no pinned categories. You can pin them from the{' '} <Link to="/bookmarks">/bookmarks</Link> menu - </p> + </Message> ); } else { bookmarks = ( - <p className={classes.BookmarksMessage}> + <Message> You don't have any bookmarks. You can add a new one from{' '} <Link to="/bookmarks">/bookmarks</Link> menu - </p> + </Message> ); } } diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx deleted file mode 100644 index 2cc4878..0000000 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { KeyboardEvent, useState, useEffect, Fragment } from 'react'; -import { - DragDropContext, - Droppable, - Draggable, - DropResult, -} from 'react-beautiful-dnd'; -import { Link } from 'react-router-dom'; - -// Redux -import { useDispatch, useSelector } from 'react-redux'; -import { State } from '../../../store/reducers'; -import { bindActionCreators } from 'redux'; -import { actionCreators } from '../../../store'; - -// Typescript -import { Bookmark, Category } from '../../../interfaces'; -import { ContentType } from '../Bookmarks'; - -// CSS -import classes from './BookmarkTable.module.css'; - -// UI -import { Table, Icon } from '../../UI'; - -interface Props { - contentType: ContentType; - categories: Category[]; - updateHandler: (data: Category | Bookmark) => void; -} - -export const BookmarkTable = (props: Props): JSX.Element => { - const { config } = useSelector((state: State) => state.config); - - const dispatch = useDispatch(); - const { - pinCategory, - deleteCategory, - deleteBookmark, - createNotification, - reorderCategories, - } = bindActionCreators(actionCreators, dispatch); - - const [localCategories, setLocalCategories] = useState<Category[]>([]); - const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false); - - // Copy categories array - useEffect(() => { - setLocalCategories([...props.categories]); - }, [props.categories]); - - // Check ordering - useEffect(() => { - const order = config.useOrdering; - - if (order === 'orderId') { - setIsCustomOrder(true); - } - }); - - const deleteCategoryHandler = (category: Category): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks` - ); - - if (proceed) { - deleteCategory(category.id); - } - }; - - const deleteBookmarkHandler = (bookmark: Bookmark): void => { - const proceed = window.confirm( - `Are you sure you want to delete ${bookmark.name}?` - ); - - if (proceed) { - deleteBookmark(bookmark.id, bookmark.categoryId); - } - }; - - const keyboardActionHandler = ( - e: KeyboardEvent, - category: Category, - handler: Function - ) => { - if (e.key === 'Enter') { - handler(category); - } - }; - - const dragEndHanlder = (result: DropResult): void => { - if (!isCustomOrder) { - createNotification({ - title: 'Error', - message: 'Custom order is disabled', - }); - return; - } - - if (!result.destination) { - return; - } - - const tmpCategories = [...localCategories]; - const [movedApp] = tmpCategories.splice(result.source.index, 1); - tmpCategories.splice(result.destination.index, 0, movedApp); - - setLocalCategories(tmpCategories); - reorderCategories(tmpCategories); - }; - - if (props.contentType === ContentType.category) { - return ( - <Fragment> - <div className={classes.Message}> - {isCustomOrder ? ( - <p>You can drag and drop single rows to reorder categories</p> - ) : ( - <p> - Custom order is disabled. You can change it in{' '} - <Link to="/settings/other">settings</Link> - </p> - )} - </div> - <DragDropContext onDragEnd={dragEndHanlder}> - <Droppable droppableId="categories"> - {(provided) => ( - <Table - headers={['Name', 'Visibility', 'Actions']} - innerRef={provided.innerRef} - > - {localCategories.map( - (category: Category, index): JSX.Element => { - return ( - <Draggable - key={category.id} - draggableId={category.id.toString()} - index={index} - > - {(provided, snapshot) => { - const style = { - border: snapshot.isDragging - ? '1px solid var(--color-accent)' - : 'none', - borderRadius: '4px', - ...provided.draggableProps.style, - }; - - return ( - <tr - {...provided.draggableProps} - {...provided.dragHandleProps} - ref={provided.innerRef} - style={style} - > - <td style={{ width: '300px' }}> - {category.name} - </td> - <td style={{ width: '300px' }}> - {category.isPublic ? 'Visible' : 'Hidden'} - </td> - {!snapshot.isDragging && ( - <td className={classes.TableActions}> - <div - className={classes.TableAction} - onClick={() => - deleteCategoryHandler(category) - } - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - deleteCategoryHandler - ) - } - tabIndex={0} - > - <Icon icon="mdiDelete" /> - </div> - <div - className={classes.TableAction} - onClick={() => - props.updateHandler(category) - } - tabIndex={0} - > - <Icon icon="mdiPencil" /> - </div> - <div - className={classes.TableAction} - onClick={() => pinCategory(category)} - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - pinCategory - ) - } - tabIndex={0} - > - {category.isPinned ? ( - <Icon - icon="mdiPinOff" - color="var(--color-accent)" - /> - ) : ( - <Icon icon="mdiPin" /> - )} - </div> - </td> - )} - </tr> - ); - }} - </Draggable> - ); - } - )} - </Table> - )} - </Droppable> - </DragDropContext> - </Fragment> - ); - } else { - const bookmarks: { bookmark: Bookmark; categoryName: string }[] = []; - props.categories.forEach((category: Category) => { - category.bookmarks.forEach((bookmark: Bookmark) => { - bookmarks.push({ - bookmark, - categoryName: category.name, - }); - }); - }); - - return ( - <Table - headers={['Name', 'URL', 'Icon', 'Visibility', 'Category', 'Actions']} - > - {bookmarks.map( - (bookmark: { bookmark: Bookmark; categoryName: string }) => { - return ( - <tr key={bookmark.bookmark.id}> - <td>{bookmark.bookmark.name}</td> - <td>{bookmark.bookmark.url}</td> - <td>{bookmark.bookmark.icon}</td> - <td>{bookmark.bookmark.isPublic ? 'Visible' : 'Hidden'}</td> - <td>{bookmark.categoryName}</td> - <td className={classes.TableActions}> - <div - className={classes.TableAction} - onClick={() => deleteBookmarkHandler(bookmark.bookmark)} - tabIndex={0} - > - <Icon icon="mdiDelete" /> - </div> - <div - className={classes.TableAction} - onClick={() => props.updateHandler(bookmark.bookmark)} - tabIndex={0} - > - <Icon icon="mdiPencil" /> - </div> - </td> - </tr> - ); - } - )} - </Table> - ); - } -}; diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 62a2e15..a708995 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/client/src/components/Bookmarks/Bookmarks.tsx @@ -18,11 +18,8 @@ import { Container, Headline, ActionButton, Spinner, Modal } from '../UI'; // Components import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid'; -import { BookmarkTable } from './BookmarkTable/BookmarkTable'; import { Form } from './Form/Form'; - -// Utils -import { bookmarkTemplate, categoryTemplate } from '../../utility'; +import { Table } from './Table/Table'; interface Props { searching: boolean; @@ -34,76 +31,96 @@ export enum ContentType { } export const Bookmarks = (props: Props): JSX.Element => { + // Get Redux state const { - bookmarks: { loading, categories }, + bookmarks: { loading, categories, categoryInEdit }, auth: { isAuthenticated }, } = useSelector((state: State) => state); + // Get Redux action creators const dispatch = useDispatch(); - const { getCategories } = bindActionCreators(actionCreators, dispatch); - - 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<Category>(categoryTemplate); - const [bookmarkInUpdate, setBookmarkInUpdate] = - useState<Bookmark>(bookmarkTemplate); + const { getCategories, setEditCategory, setEditBookmark } = + bindActionCreators(actionCreators, dispatch); + // Load categories if array is empty useEffect(() => { if (!categories.length) { getCategories(); } }, []); - // observe if user is authenticated -> set default view if not + // Form + const [modalIsOpen, setModalIsOpen] = useState(false); + const [formContentType, setFormContentType] = useState(ContentType.category); + const [isInUpdate, setIsInUpdate] = useState(false); + + // Table + const [showTable, setShowTable] = useState(false); + const [tableContentType, setTableContentType] = useState( + ContentType.category + ); + + // Observe if user is authenticated -> set default view (grid) if not useEffect(() => { if (!isAuthenticated) { - setIsInEdit(false); + setShowTable(false); setModalIsOpen(false); } }, [isAuthenticated]); + useEffect(() => { + if (categoryInEdit && !modalIsOpen) { + setTableContentType(ContentType.bookmark); + setShowTable(true); + } + }, [categoryInEdit]); + + useEffect(() => { + setShowTable(false); + setEditCategory(null); + }, []); + + // Form actions const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); }; - const addActionHandler = (contentType: ContentType) => { + const openFormForAdding = (contentType: ContentType) => { setFormContentType(contentType); setIsInUpdate(false); toggleModal(); }; - 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 'bookmarks' in object; - }; - - const goToUpdateMode = (data: Category | Bookmark): void => { + const openFormForUpdating = (data: Category | Bookmark): void => { setIsInUpdate(true); + + const instanceOfCategory = (object: any): object is Category => { + return 'bookmarks' in object; + }; + if (instanceOfCategory(data)) { setFormContentType(ContentType.category); - setCategoryInUpdate(data); + setEditCategory(data); } else { setFormContentType(ContentType.bookmark); - setBookmarkInUpdate(data); + setEditBookmark(data); } + toggleModal(); }; + // Table actions + const showTableForEditing = (contentType: ContentType) => { + // We're in the edit mode and the same button was clicked - go back to list + if (showTable && contentType === tableContentType) { + setEditCategory(null); + setShowTable(false); + } else { + setShowTable(true); + setTableContentType(contentType); + } + }; + return ( <Container> <Modal isOpen={modalIsOpen} setIsOpen={toggleModal}> @@ -111,8 +128,6 @@ export const Bookmarks = (props: Props): JSX.Element => { modalHandler={toggleModal} contentType={formContentType} inUpdate={isInUpdate} - category={categoryInUpdate} - bookmark={bookmarkInUpdate} /> </Modal> @@ -123,35 +138,34 @@ export const Bookmarks = (props: Props): JSX.Element => { <ActionButton name="Add Category" icon="mdiPlusBox" - handler={() => addActionHandler(ContentType.category)} + handler={() => openFormForAdding(ContentType.category)} /> <ActionButton name="Add Bookmark" icon="mdiPlusBox" - handler={() => addActionHandler(ContentType.bookmark)} + handler={() => openFormForAdding(ContentType.bookmark)} /> <ActionButton name="Edit Categories" icon="mdiPencil" - handler={() => editActionHandler(ContentType.category)} + handler={() => showTableForEditing(ContentType.category)} /> <ActionButton name="Edit Bookmarks" icon="mdiPencil" - handler={() => editActionHandler(ContentType.bookmark)} + handler={() => showTableForEditing(ContentType.bookmark)} /> </div> )} {loading ? ( <Spinner /> - ) : !isInEdit ? ( + ) : !showTable ? ( <BookmarkGrid categories={categories} searching={props.searching} /> ) : ( - <BookmarkTable + <Table contentType={tableContentType} - categories={categories} - updateHandler={goToUpdateMode} + openFormForUpdating={openFormForUpdating} /> )} </Container> diff --git a/client/src/components/Bookmarks/Form/BookmarksForm.tsx b/client/src/components/Bookmarks/Form/BookmarksForm.tsx index f0a3a43..893b334 100644 --- a/client/src/components/Bookmarks/Form/BookmarksForm.tsx +++ b/client/src/components/Bookmarks/Form/BookmarksForm.tsx @@ -137,15 +137,15 @@ export const BookmarksForm = ({ } modalHandler(); - - setFormData(newBookmarkTemplate); - - setCustomIcon(null); } + + setFormData({ ...newBookmarkTemplate, categoryId: formData.categoryId }); + setCustomIcon(null); }; return ( <ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}> + {/* NAME */} <InputGroup> <label htmlFor="name">Bookmark Name</label> <input @@ -159,6 +159,7 @@ export const BookmarksForm = ({ /> </InputGroup> + {/* URL */} <InputGroup> <label htmlFor="url">Bookmark URL</label> <input @@ -172,6 +173,7 @@ export const BookmarksForm = ({ /> </InputGroup> + {/* CATEGORY */} <InputGroup> <label htmlFor="categoryId">Bookmark Category</label> <select @@ -192,6 +194,7 @@ export const BookmarksForm = ({ </select> </InputGroup> + {/* ICON */} {!useCustomIcon ? ( // mdi <InputGroup> @@ -227,7 +230,7 @@ export const BookmarksForm = ({ name="icon" id="icon" onChange={(e) => fileChangeHandler(e)} - accept=".jpg,.jpeg,.png,.svg" + accept=".jpg,.jpeg,.png,.svg,.ico" /> <span onClick={() => { @@ -241,6 +244,7 @@ export const BookmarksForm = ({ </InputGroup> )} + {/* VISIBILTY */} <InputGroup> <label htmlFor="isPublic">Bookmark visibility</label> <select diff --git a/client/src/components/Bookmarks/Form/CategoryForm.tsx b/client/src/components/Bookmarks/Form/CategoryForm.tsx index c7e0105..b7ffb58 100644 --- a/client/src/components/Bookmarks/Form/CategoryForm.tsx +++ b/client/src/components/Bookmarks/Form/CategoryForm.tsx @@ -60,10 +60,10 @@ export const CategoryForm = ({ addCategory(formData); } else { updateCategory(category.id, formData); + modalHandler(); } setFormData(newCategoryTemplate); - modalHandler(); }; return ( diff --git a/client/src/components/Bookmarks/Form/Form.tsx b/client/src/components/Bookmarks/Form/Form.tsx index 41ed1bb..960e8cf 100644 --- a/client/src/components/Bookmarks/Form/Form.tsx +++ b/client/src/components/Bookmarks/Form/Form.tsx @@ -1,22 +1,26 @@ // Typescript -import { Bookmark, Category } from '../../../interfaces'; import { ContentType } from '../Bookmarks'; // Utils import { CategoryForm } from './CategoryForm'; import { BookmarksForm } from './BookmarksForm'; import { Fragment } from 'react'; +import { useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bookmarkTemplate, categoryTemplate } from '../../../utility'; interface Props { modalHandler: () => void; contentType: ContentType; inUpdate?: boolean; - category?: Category; - bookmark?: Bookmark; } export const Form = (props: Props): JSX.Element => { - const { modalHandler, contentType, inUpdate, category, bookmark } = props; + const { categoryInEdit, bookmarkInEdit } = useSelector( + (state: State) => state.bookmarks + ); + + const { modalHandler, contentType, inUpdate } = props; return ( <Fragment> @@ -33,9 +37,15 @@ export const Form = (props: Props): JSX.Element => { // form: update <Fragment> {contentType === ContentType.category ? ( - <CategoryForm modalHandler={modalHandler} category={category} /> + <CategoryForm + modalHandler={modalHandler} + category={categoryInEdit || categoryTemplate} + /> ) : ( - <BookmarksForm modalHandler={modalHandler} bookmark={bookmark} /> + <BookmarksForm + modalHandler={modalHandler} + bookmark={bookmarkInEdit || bookmarkTemplate} + /> )} </Fragment> )} diff --git a/client/src/components/Bookmarks/Table/BookmarksTable.tsx b/client/src/components/Bookmarks/Table/BookmarksTable.tsx new file mode 100644 index 0000000..dd0f447 --- /dev/null +++ b/client/src/components/Bookmarks/Table/BookmarksTable.tsx @@ -0,0 +1,195 @@ +import { useState, useEffect, Fragment } from 'react'; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from 'react-beautiful-dnd'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { Bookmark, Category } from '../../../interfaces'; + +// CSS +import classes from './Table.module.css'; + +// UI +import { Table } from '../../UI'; +import { TableActions } from '../../Actions/TableActions'; +import { bookmarkTemplate } from '../../../utility'; + +interface Props { + openFormForUpdating: (data: Category | Bookmark) => void; +} + +export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => { + const { + bookmarks: { categoryInEdit }, + config: { config }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { + deleteBookmark, + updateBookmark, + createNotification, + reorderBookmarks, + } = bindActionCreators(actionCreators, dispatch); + + const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]); + + // Copy bookmarks array + useEffect(() => { + if (categoryInEdit) { + setLocalBookmarks([...categoryInEdit.bookmarks]); + } + }, [categoryInEdit]); + + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', + }); + return; + } + + if (!result.destination) { + return; + } + + const tmpBookmarks = [...localBookmarks]; + const [movedBookmark] = tmpBookmarks.splice(result.source.index, 1); + tmpBookmarks.splice(result.destination.index, 0, movedBookmark); + + setLocalBookmarks(tmpBookmarks); + + const categoryId = categoryInEdit?.id || -1; + reorderBookmarks(tmpBookmarks, categoryId); + }; + + // Action hanlders + const deleteBookmarkHandler = (id: number, name: string) => { + const categoryId = categoryInEdit?.id || -1; + + const proceed = window.confirm(`Are you sure you want to delete ${name}?`); + if (proceed) { + deleteBookmark(id, categoryId); + } + }; + + const updateBookmarkHandler = (id: number) => { + const bookmark = + categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate; + + openFormForUpdating(bookmark); + }; + + const changeBookmarkVisibiltyHandler = (id: number) => { + const bookmark = + categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate; + + const categoryId = categoryInEdit?.id || -1; + const [prev, curr] = [categoryId, categoryId]; + + updateBookmark( + id, + { ...bookmark, isPublic: !bookmark.isPublic }, + { prev, curr } + ); + }; + + return ( + <Fragment> + {!categoryInEdit ? ( + <div className={classes.Message}> + <p> + Switch to grid view and click on the name of category you want to + edit + </p> + </div> + ) : ( + <div className={classes.Message}> + <p> + Editing bookmarks from <span>{categoryInEdit.name}</span> category + </p> + </div> + )} + + {categoryInEdit && ( + <DragDropContext onDragEnd={dragEndHanlder}> + <Droppable droppableId="bookmarks"> + {(provided) => ( + <Table + headers={[ + 'Name', + 'URL', + 'Icon', + 'Visibility', + 'Category', + 'Actions', + ]} + innerRef={provided.innerRef} + > + {localBookmarks.map((bookmark, index): JSX.Element => { + return ( + <Draggable + key={bookmark.id} + draggableId={bookmark.id.toString()} + index={index} + > + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + <tr + {...provided.draggableProps} + {...provided.dragHandleProps} + ref={provided.innerRef} + style={style} + > + <td style={{ width: '200px' }}>{bookmark.name}</td> + <td style={{ width: '200px' }}>{bookmark.url}</td> + <td style={{ width: '200px' }}>{bookmark.icon}</td> + <td style={{ width: '200px' }}> + {bookmark.isPublic ? 'Visible' : 'Hidden'} + </td> + <td style={{ width: '200px' }}> + {categoryInEdit.name} + </td> + + {!snapshot.isDragging && ( + <TableActions + entity={bookmark} + deleteHandler={deleteBookmarkHandler} + updateHandler={updateBookmarkHandler} + changeVisibilty={changeBookmarkVisibiltyHandler} + showPin={false} + /> + )} + </tr> + ); + }} + </Draggable> + ); + })} + </Table> + )} + </Droppable> + </DragDropContext> + )} + </Fragment> + ); +}; diff --git a/client/src/components/Bookmarks/Table/CategoryTable.tsx b/client/src/components/Bookmarks/Table/CategoryTable.tsx new file mode 100644 index 0000000..124bd35 --- /dev/null +++ b/client/src/components/Bookmarks/Table/CategoryTable.tsx @@ -0,0 +1,169 @@ +import { useState, useEffect, Fragment } from 'react'; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from 'react-beautiful-dnd'; +import { Link } from 'react-router-dom'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { Bookmark, Category } from '../../../interfaces'; + +// CSS +import classes from './Table.module.css'; + +// UI +import { Table } from '../../UI'; +import { TableActions } from '../../Actions/TableActions'; + +interface Props { + openFormForUpdating: (data: Category | Bookmark) => void; +} + +export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => { + const { + config: { config }, + bookmarks: { categories }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { + pinCategory, + deleteCategory, + createNotification, + reorderCategories, + updateCategory, + } = bindActionCreators(actionCreators, dispatch); + + const [localCategories, setLocalCategories] = useState<Category[]>([]); + + // Copy categories array + useEffect(() => { + setLocalCategories([...categories]); + }, [categories]); + + // Drag and drop handler + const dragEndHanlder = (result: DropResult): void => { + if (config.useOrdering !== 'orderId') { + createNotification({ + title: 'Error', + message: 'Custom order is disabled', + }); + return; + } + + if (!result.destination) { + return; + } + + const tmpCategories = [...localCategories]; + const [movedCategory] = tmpCategories.splice(result.source.index, 1); + tmpCategories.splice(result.destination.index, 0, movedCategory); + + setLocalCategories(tmpCategories); + reorderCategories(tmpCategories); + }; + + // Action handlers + const deleteCategoryHandler = (id: number, name: string) => { + const proceed = window.confirm( + `Are you sure you want to delete ${name}? It will delete ALL assigned bookmarks` + ); + + if (proceed) { + deleteCategory(id); + } + }; + + const updateCategoryHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + openFormForUpdating(category); + }; + + const pinCategoryHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + pinCategory(category); + }; + + const changeCategoryVisibiltyHandler = (id: number) => { + const category = categories.find((c) => c.id === id) as Category; + updateCategory(id, { ...category, isPublic: !category.isPublic }); + }; + + return ( + <Fragment> + <div className={classes.Message}> + {config.useOrdering === 'orderId' ? ( + <p>You can drag and drop single rows to reorder categories</p> + ) : ( + <p> + Custom order is disabled. You can change it in the{' '} + <Link to="/settings/interface">settings</Link> + </p> + )} + </div> + + <DragDropContext onDragEnd={dragEndHanlder}> + <Droppable droppableId="categories"> + {(provided) => ( + <Table + headers={['Name', 'Visibility', 'Actions']} + innerRef={provided.innerRef} + > + {localCategories.map((category, index): JSX.Element => { + return ( + <Draggable + key={category.id} + draggableId={category.id.toString()} + index={index} + > + {(provided, snapshot) => { + const style = { + border: snapshot.isDragging + ? '1px solid var(--color-accent)' + : 'none', + borderRadius: '4px', + ...provided.draggableProps.style, + }; + + return ( + <tr + {...provided.draggableProps} + {...provided.dragHandleProps} + ref={provided.innerRef} + style={style} + > + <td style={{ width: '300px' }}>{category.name}</td> + <td style={{ width: '300px' }}> + {category.isPublic ? 'Visible' : 'Hidden'} + </td> + + {!snapshot.isDragging && ( + <TableActions + entity={category} + deleteHandler={deleteCategoryHandler} + updateHandler={updateCategoryHandler} + pinHanlder={pinCategoryHandler} + changeVisibilty={changeCategoryVisibiltyHandler} + /> + )} + </tr> + ); + }} + </Draggable> + ); + })} + </Table> + )} + </Droppable> + </DragDropContext> + </Fragment> + ); +}; diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css b/client/src/components/Bookmarks/Table/Table.module.css similarity index 60% rename from client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css rename to client/src/components/Bookmarks/Table/Table.module.css index 8b1e0ed..89ff6ca 100644 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css +++ b/client/src/components/Bookmarks/Table/Table.module.css @@ -1,16 +1,3 @@ -.TableActions { - display: flex; - align-items: center; -} - -.TableAction { - width: 22px; -} - -.TableAction:hover { - cursor: pointer; -} - .Message { width: 100%; display: flex; @@ -20,10 +7,11 @@ margin-bottom: 20px; } -.Message a { +.Message a, +.Message span { color: var(--color-accent); } .Message a:hover { cursor: pointer; -} \ No newline at end of file +} diff --git a/client/src/components/Bookmarks/Table/Table.tsx b/client/src/components/Bookmarks/Table/Table.tsx new file mode 100644 index 0000000..8704fdb --- /dev/null +++ b/client/src/components/Bookmarks/Table/Table.tsx @@ -0,0 +1,20 @@ +import { Category, Bookmark } from '../../../interfaces'; +import { ContentType } from '../Bookmarks'; +import { BookmarksTable } from './BookmarksTable'; +import { CategoryTable } from './CategoryTable'; + +interface Props { + contentType: ContentType; + openFormForUpdating: (data: Category | Bookmark) => void; +} + +export const Table = (props: Props): JSX.Element => { + const tableEl = + props.contentType === ContentType.category ? ( + <CategoryTable openFormForUpdating={props.openFormForUpdating} /> + ) : ( + <BookmarksTable openFormForUpdating={props.openFormForUpdating} /> + ); + + return tableEl; +}; diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index d7f3872..e24e5c0 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -11,7 +11,7 @@ import { actionCreators } from '../../store'; import { App, Category } from '../../interfaces'; // UI -import { Icon, Container, SectionHeadline, Spinner } from '../UI'; +import { Icon, Container, SectionHeadline, Spinner, Message } from '../UI'; // CSS import classes from './Home.module.css'; @@ -30,6 +30,7 @@ export const Home = (): JSX.Element => { apps: { apps, loading: appsLoading }, bookmarks: { categories, loading: bookmarksLoading }, config: { config }, + auth: { isAuthenticated }, } = useSelector((state: State) => state); const dispatch = useDispatch(); @@ -100,7 +101,18 @@ export const Home = (): JSX.Element => { <Header /> - {!config.hideApps ? ( + {!isAuthenticated && + !apps.some((a) => a.isPinned) && + !categories.some((c) => c.isPinned) ? ( + <Message> + Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>, + login and start customizing your new homepage + </Message> + ) : ( + <></> + )} + + {!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? ( <Fragment> <SectionHeadline title="Applications" link="/applications" /> {appsLoading ? ( @@ -119,10 +131,11 @@ export const Home = (): JSX.Element => { <div className={classes.HomeSpace}></div> </Fragment> ) : ( - <div></div> + <></> )} - {!config.hideCategories ? ( + {!config.hideCategories && + (isAuthenticated || categories.some((c) => c.isPinned)) ? ( <Fragment> <SectionHeadline title="Bookmarks" link="/bookmarks" /> {bookmarksLoading ? ( @@ -138,11 +151,12 @@ export const Home = (): JSX.Element => { } totalCategories={categories.length} searching={!!localSearch} + fromHomepage={true} /> )} </Fragment> ) : ( - <div></div> + <></> )} <Link to="/settings" className={classes.SettingsButton}> diff --git a/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx index 2742d76..d0f6466 100644 --- a/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx +++ b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx @@ -1,4 +1,4 @@ -import { FormEvent, Fragment, useEffect, useState } from 'react'; +import { FormEvent, Fragment, useEffect, useState, useRef } from 'react'; // Redux import { useSelector, useDispatch } from 'react-redux'; @@ -23,6 +23,12 @@ export const AuthForm = (): JSX.Element => { duration: '14d', }); + const passwordInputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + passwordInputRef.current?.focus(); + }, []); + useEffect(() => { if (token) { const decoded = decodeToken(token); @@ -52,6 +58,7 @@ export const AuthForm = (): JSX.Element => { name="password" placeholder="••••••" autoComplete="current-password" + ref={passwordInputRef} value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value }) diff --git a/client/src/components/Settings/UISettings/UISettings.tsx b/client/src/components/Settings/UISettings/UISettings.tsx index b0de452..075a427 100644 --- a/client/src/components/Settings/UISettings/UISettings.tsx +++ b/client/src/components/Settings/UISettings/UISettings.tsx @@ -16,13 +16,14 @@ import { InputGroup, Button, SettingsHeadline } from '../../UI'; import { otherSettingsTemplate, inputHandler } from '../../../utility'; export const UISettings = (): JSX.Element => { - const { loading, config } = useSelector((state: State) => state.config); + const { + config: { loading, config }, + bookmarks: { categories }, + } = useSelector((state: State) => state); const dispatch = useDispatch(); - const { updateConfig, sortApps, sortCategories } = bindActionCreators( - actionCreators, - dispatch - ); + const { updateConfig, sortApps, sortCategories, sortBookmarks } = + bindActionCreators(actionCreators, dispatch); // Initial state const [formData, setFormData] = useState<OtherSettingsForm>( @@ -46,9 +47,15 @@ export const UISettings = (): JSX.Element => { // Update local page title document.title = formData.customTitle; - // Sort apps and categories with new settings - sortApps(); - sortCategories(); + // Sort entities with new settings + if (formData.useOrdering !== config.useOrdering) { + sortApps(); + sortCategories(); + + for (let { id } of categories) { + sortBookmarks(id); + } + } }; // Input handler @@ -85,7 +92,9 @@ export const UISettings = (): JSX.Element => { <SettingsHeadline text="Header" /> {/* HIDE HEADER */} <InputGroup> - <label htmlFor="hideHeader">Hide greetings</label> + <label htmlFor="hideHeader"> + Hide headline (greetings and weather) + </label> <select id="hideHeader" name="hideHeader" diff --git a/client/src/components/UI/Text/Message/Message.module.css b/client/src/components/UI/Text/Message/Message.module.css new file mode 100644 index 0000000..e459212 --- /dev/null +++ b/client/src/components/UI/Text/Message/Message.module.css @@ -0,0 +1,8 @@ +.message { + color: var(--color-primary); +} + +.message a { + color: var(--color-accent); + font-weight: 600; +} diff --git a/client/src/components/UI/Text/Message/Message.tsx b/client/src/components/UI/Text/Message/Message.tsx new file mode 100644 index 0000000..2409f1c --- /dev/null +++ b/client/src/components/UI/Text/Message/Message.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react'; + +import classes from './Message.module.css'; + +interface Props { + children: ReactNode; +} + +export const Message = ({ children }: Props): JSX.Element => { + return <p className={classes.message}>{children}</p>; +}; diff --git a/client/src/components/UI/index.ts b/client/src/components/UI/index.ts index e1c0917..23d5f73 100644 --- a/client/src/components/UI/index.ts +++ b/client/src/components/UI/index.ts @@ -12,3 +12,4 @@ export * from './Forms/InputGroup/InputGroup'; export * from './Forms/ModalForm/ModalForm'; export * from './Buttons/ActionButton/ActionButton'; export * from './Buttons/Button/Button'; +export * from './Text/Message/Message'; diff --git a/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx b/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx index 1664eff..d300328 100644 --- a/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx +++ b/client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx @@ -71,7 +71,7 @@ export const WeatherWidget = (): JSX.Element => { {config.isCelsius ? ( <span>{weather.tempC}°C</span> ) : ( - <span>{weather.tempF}°F</span> + <span>{Math.round(weather.tempF)}°F</span> )} {/* ADDITIONAL DATA */} diff --git a/client/src/interfaces/Bookmark.ts b/client/src/interfaces/Bookmark.ts index db10380..858101c 100644 --- a/client/src/interfaces/Bookmark.ts +++ b/client/src/interfaces/Bookmark.ts @@ -8,4 +8,6 @@ export interface NewBookmark { isPublic: boolean; } -export interface Bookmark extends Model, NewBookmark {} +export interface Bookmark extends Model, NewBookmark { + orderId: number; +} diff --git a/client/src/store/action-creators/app.ts b/client/src/store/action-creators/app.ts index ddd88fa..7e285b6 100644 --- a/client/src/store/action-creators/app.ts +++ b/client/src/store/action-creators/app.ts @@ -7,6 +7,7 @@ import { GetAppsAction, PinAppAction, ReorderAppsAction, + SetEditAppAction, SortAppsAction, UpdateAppAction, } from '../actions/app'; @@ -196,3 +197,11 @@ export const sortApps = () => async (dispatch: Dispatch<SortAppsAction>) => { console.log(err); } }; + +export const setEditApp = + (app: App | null) => (dispatch: Dispatch<SetEditAppAction>) => { + dispatch({ + type: ActionType.setEditApp, + payload: app, + }); + }; diff --git a/client/src/store/action-creators/bookmark.ts b/client/src/store/action-creators/bookmark.ts index 5010bd3..5f077a6 100644 --- a/client/src/store/action-creators/bookmark.ts +++ b/client/src/store/action-creators/bookmark.ts @@ -1,5 +1,8 @@ import axios from 'axios'; import { Dispatch } from 'redux'; +import { applyAuth } from '../../utility'; +import { ActionType } from '../action-types'; + import { ApiResponse, Bookmark, @@ -8,8 +11,7 @@ import { NewBookmark, NewCategory, } from '../../interfaces'; -import { applyAuth } from '../../utility'; -import { ActionType } from '../action-types'; + import { AddBookmarkAction, AddCategoryAction, @@ -17,7 +19,11 @@ import { DeleteCategoryAction, GetCategoriesAction, PinCategoryAction, + ReorderBookmarksAction, ReorderCategoriesAction, + SetEditBookmarkAction, + SetEditCategoryAction, + SortBookmarksAction, SortCategoriesAction, UpdateBookmarkAction, UpdateCategoryAction, @@ -95,6 +101,8 @@ export const addBookmark = type: ActionType.addBookmark, payload: res.data.data, }); + + dispatch<any>(sortBookmarks(res.data.data.categoryId)); } catch (err) { console.log(err); } @@ -266,6 +274,8 @@ export const updateBookmark = payload: res.data.data, }); } + + dispatch<any>(sortBookmarks(res.data.data.categoryId)); } catch (err) { console.log(err); } @@ -319,3 +329,73 @@ export const reorderCategories = console.log(err); } }; + +export const setEditCategory = + (category: Category | null) => + (dispatch: Dispatch<SetEditCategoryAction>) => { + dispatch({ + type: ActionType.setEditCategory, + payload: category, + }); + }; + +export const setEditBookmark = + (bookmark: Bookmark | null) => + (dispatch: Dispatch<SetEditBookmarkAction>) => { + dispatch({ + type: ActionType.setEditBookmark, + payload: bookmark, + }); + }; + +export const reorderBookmarks = + (bookmarks: Bookmark[], categoryId: number) => + async (dispatch: Dispatch<ReorderBookmarksAction>) => { + interface ReorderQuery { + bookmarks: { + id: number; + orderId: number; + }[]; + } + + try { + const updateQuery: ReorderQuery = { bookmarks: [] }; + + bookmarks.forEach((bookmark, index) => + updateQuery.bookmarks.push({ + id: bookmark.id, + orderId: index + 1, + }) + ); + + await axios.put<ApiResponse<{}>>( + '/api/bookmarks/0/reorder', + updateQuery, + { headers: applyAuth() } + ); + + dispatch({ + type: ActionType.reorderBookmarks, + payload: { bookmarks, categoryId }, + }); + } catch (err) { + console.log(err); + } + }; + +export const sortBookmarks = + (categoryId: number) => async (dispatch: Dispatch<SortBookmarksAction>) => { + try { + const res = await axios.get<ApiResponse<Config>>('/api/config'); + + dispatch({ + type: ActionType.sortBookmarks, + payload: { + orderType: res.data.data.useOrdering, + categoryId, + }, + }); + } catch (err) { + console.log(err); + } + }; diff --git a/client/src/store/action-types/index.ts b/client/src/store/action-types/index.ts index 58ca529..4be159f 100644 --- a/client/src/store/action-types/index.ts +++ b/client/src/store/action-types/index.ts @@ -23,6 +23,7 @@ export enum ActionType { updateApp = 'UPDATE_APP', reorderApps = 'REORDER_APPS', sortApps = 'SORT_APPS', + setEditApp = 'SET_EDIT_APP', // CATEGORES getCategories = 'GET_CATEGORIES', getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS', @@ -33,10 +34,14 @@ export enum ActionType { updateCategory = 'UPDATE_CATEGORY', sortCategories = 'SORT_CATEGORIES', reorderCategories = 'REORDER_CATEGORIES', + setEditCategory = 'SET_EDIT_CATEGORY', // BOOKMARKS addBookmark = 'ADD_BOOKMARK', deleteBookmark = 'DELETE_BOOKMARK', updateBookmark = 'UPDATE_BOOKMARK', + setEditBookmark = 'SET_EDIT_BOOKMARK', + reorderBookmarks = 'REORDER_BOOKMARKS', + sortBookmarks = 'SORT_BOOKMARKS', // AUTH login = 'LOGIN', logout = 'LOGOUT', diff --git a/client/src/store/actions/app.ts b/client/src/store/actions/app.ts index 37f5419..689014a 100644 --- a/client/src/store/actions/app.ts +++ b/client/src/store/actions/app.ts @@ -36,3 +36,8 @@ export interface SortAppsAction { type: ActionType.sortApps; payload: string; } + +export interface SetEditAppAction { + type: ActionType.setEditApp; + payload: App | null; +} diff --git a/client/src/store/actions/bookmark.ts b/client/src/store/actions/bookmark.ts index e4cfcfd..7c9e1f2 100644 --- a/client/src/store/actions/bookmark.ts +++ b/client/src/store/actions/bookmark.ts @@ -56,3 +56,29 @@ export interface ReorderCategoriesAction { type: ActionType.reorderCategories; payload: Category[]; } + +export interface SetEditCategoryAction { + type: ActionType.setEditCategory; + payload: Category | null; +} + +export interface SetEditBookmarkAction { + type: ActionType.setEditBookmark; + payload: Bookmark | null; +} + +export interface ReorderBookmarksAction { + type: ActionType.reorderBookmarks; + payload: { + bookmarks: Bookmark[]; + categoryId: number; + }; +} + +export interface SortBookmarksAction { + type: ActionType.sortBookmarks; + payload: { + orderType: string; + categoryId: number; + }; +} diff --git a/client/src/store/actions/index.ts b/client/src/store/actions/index.ts index 02862b6..bd0b360 100644 --- a/client/src/store/actions/index.ts +++ b/client/src/store/actions/index.ts @@ -24,6 +24,7 @@ import { UpdateAppAction, ReorderAppsAction, SortAppsAction, + SetEditAppAction, } from './app'; import { @@ -37,6 +38,10 @@ import { AddBookmarkAction, DeleteBookmarkAction, UpdateBookmarkAction, + SetEditCategoryAction, + SetEditBookmarkAction, + ReorderBookmarksAction, + SortBookmarksAction, } from './bookmark'; import { @@ -67,6 +72,7 @@ export type Action = | UpdateAppAction | ReorderAppsAction | SortAppsAction + | SetEditAppAction // Categories | GetCategoriesAction<any> | AddCategoryAction @@ -75,10 +81,14 @@ export type Action = | UpdateCategoryAction | SortCategoriesAction | ReorderCategoriesAction + | SetEditCategoryAction // Bookmarks | AddBookmarkAction | DeleteBookmarkAction | UpdateBookmarkAction + | SetEditBookmarkAction + | ReorderBookmarksAction + | SortBookmarksAction // Auth | LoginAction | LogoutAction diff --git a/client/src/store/reducers/app.ts b/client/src/store/reducers/app.ts index e6da902..3d08727 100644 --- a/client/src/store/reducers/app.ts +++ b/client/src/store/reducers/app.ts @@ -7,12 +7,14 @@ interface AppsState { loading: boolean; apps: App[]; errors: string | undefined; + appInUpdate: App | null; } const initialState: AppsState = { loading: true, apps: [], errors: undefined, + appInUpdate: null, }; export const appsReducer = ( @@ -20,71 +22,86 @@ export const appsReducer = ( action: Action ): AppsState => { switch (action.type) { - case ActionType.getApps: + case ActionType.getApps: { return { ...state, loading: true, errors: undefined, }; + } - case ActionType.getAppsSuccess: + case ActionType.getAppsSuccess: { return { ...state, loading: false, apps: action.payload || [], }; + } - case ActionType.pinApp: - const pinnedAppIdx = state.apps.findIndex( + case ActionType.pinApp: { + const appIdx = state.apps.findIndex( (app) => app.id === action.payload.id ); return { ...state, apps: [ - ...state.apps.slice(0, pinnedAppIdx), + ...state.apps.slice(0, appIdx), action.payload, - ...state.apps.slice(pinnedAppIdx + 1), + ...state.apps.slice(appIdx + 1), ], }; + } - case ActionType.addAppSuccess: + case ActionType.addAppSuccess: { return { ...state, apps: [...state.apps, action.payload], }; + } - case ActionType.deleteApp: + case ActionType.deleteApp: { return { ...state, apps: [...state.apps].filter((app) => app.id !== action.payload), }; + } - case ActionType.updateApp: - const updatedAppIdx = state.apps.findIndex( + case ActionType.updateApp: { + const appIdx = state.apps.findIndex( (app) => app.id === action.payload.id ); return { ...state, apps: [ - ...state.apps.slice(0, updatedAppIdx), + ...state.apps.slice(0, appIdx), action.payload, - ...state.apps.slice(updatedAppIdx + 1), + ...state.apps.slice(appIdx + 1), ], }; + } - case ActionType.reorderApps: + case ActionType.reorderApps: { return { ...state, apps: action.payload, }; + } - case ActionType.sortApps: + case ActionType.sortApps: { return { ...state, apps: sortData<App>(state.apps, action.payload), }; + } + + case ActionType.setEditApp: { + return { + ...state, + appInUpdate: action.payload, + }; + } default: return state; diff --git a/client/src/store/reducers/auth.ts b/client/src/store/reducers/auth.ts index 4105a0f..2281a86 100644 --- a/client/src/store/reducers/auth.ts +++ b/client/src/store/reducers/auth.ts @@ -22,24 +22,28 @@ export const authReducer = ( token: action.payload, isAuthenticated: true, }; + case ActionType.logout: return { ...state, token: null, isAuthenticated: false, }; + case ActionType.autoLogin: return { ...state, token: action.payload, isAuthenticated: true, }; + case ActionType.authError: return { ...state, token: null, isAuthenticated: false, }; + default: return state; } diff --git a/client/src/store/reducers/bookmark.ts b/client/src/store/reducers/bookmark.ts index b8e7e61..7f2510c 100644 --- a/client/src/store/reducers/bookmark.ts +++ b/client/src/store/reducers/bookmark.ts @@ -1,4 +1,4 @@ -import { Category } from '../../interfaces'; +import { Bookmark, Category } from '../../interfaces'; import { sortData } from '../../utility'; import { ActionType } from '../action-types'; import { Action } from '../actions'; @@ -7,12 +7,16 @@ interface BookmarksState { loading: boolean; errors: string | undefined; categories: Category[]; + categoryInEdit: Category | null; + bookmarkInEdit: Bookmark | null; } const initialState: BookmarksState = { loading: true, errors: undefined, categories: [], + categoryInEdit: null, + bookmarkInEdit: null, }; export const bookmarksReducer = ( @@ -20,27 +24,181 @@ export const bookmarksReducer = ( action: Action ): BookmarksState => { switch (action.type) { - case ActionType.getCategories: + case ActionType.getCategories: { return { ...state, loading: true, errors: undefined, }; + } - case ActionType.getCategoriesSuccess: + case ActionType.getCategoriesSuccess: { return { ...state, loading: false, categories: action.payload, }; + } - case ActionType.addCategory: + case ActionType.addCategory: { return { ...state, categories: [...state.categories, { ...action.payload, bookmarks: [] }], }; + } - case ActionType.addBookmark: + case ActionType.addBookmark: { + const categoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.categoryId + ); + + const targetCategory = { + ...state.categories[categoryIdx], + bookmarks: [...state.categories[categoryIdx].bookmarks, action.payload], + }; + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryIdx), + targetCategory, + ...state.categories.slice(categoryIdx + 1), + ], + categoryInEdit: targetCategory, + }; + } + + case ActionType.pinCategory: { + const categoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.id + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryIdx), + { + ...action.payload, + bookmarks: [...state.categories[categoryIdx].bookmarks], + }, + ...state.categories.slice(categoryIdx + 1), + ], + }; + } + + case ActionType.deleteCategory: { + const categoryIdx = state.categories.findIndex( + (category) => category.id === action.payload + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryIdx), + ...state.categories.slice(categoryIdx + 1), + ], + }; + } + + case ActionType.updateCategory: { + const categoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.id + ); + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryIdx), + { + ...action.payload, + bookmarks: [...state.categories[categoryIdx].bookmarks], + }, + ...state.categories.slice(categoryIdx + 1), + ], + }; + } + + case ActionType.deleteBookmark: { + const categoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.categoryId + ); + + const targetCategory = { + ...state.categories[categoryIdx], + bookmarks: state.categories[categoryIdx].bookmarks.filter( + (bookmark) => bookmark.id !== action.payload.bookmarkId + ), + }; + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryIdx), + targetCategory, + ...state.categories.slice(categoryIdx + 1), + ], + categoryInEdit: targetCategory, + }; + } + + case ActionType.updateBookmark: { + const categoryIdx = state.categories.findIndex( + (category) => category.id === action.payload.categoryId + ); + + const bookmarkIdx = state.categories[categoryIdx].bookmarks.findIndex( + (bookmark) => bookmark.id === action.payload.id + ); + + const targetCategory = { + ...state.categories[categoryIdx], + bookmarks: [ + ...state.categories[categoryIdx].bookmarks.slice(0, bookmarkIdx), + action.payload, + ...state.categories[categoryIdx].bookmarks.slice(bookmarkIdx + 1), + ], + }; + + return { + ...state, + categories: [ + ...state.categories.slice(0, categoryIdx), + targetCategory, + ...state.categories.slice(categoryIdx + 1), + ], + categoryInEdit: targetCategory, + }; + } + + case ActionType.sortCategories: { + return { + ...state, + categories: sortData<Category>(state.categories, action.payload), + }; + } + + case ActionType.reorderCategories: { + return { + ...state, + categories: action.payload, + }; + } + + case ActionType.setEditCategory: { + return { + ...state, + categoryInEdit: action.payload, + }; + } + + case ActionType.setEditBookmark: { + return { + ...state, + bookmarkInEdit: action.payload, + }; + } + + case ActionType.reorderBookmarks: { const categoryIdx = state.categories.findIndex( (category) => category.id === action.payload.categoryId ); @@ -51,121 +209,36 @@ export const bookmarksReducer = ( ...state.categories.slice(0, categoryIdx), { ...state.categories[categoryIdx], - bookmarks: [ - ...state.categories[categoryIdx].bookmarks, - action.payload, - ], + bookmarks: action.payload.bookmarks, }, ...state.categories.slice(categoryIdx + 1), ], }; + } - case ActionType.pinCategory: - const pinnedCategoryIdx = state.categories.findIndex( - (category) => category.id === action.payload.id - ); - - return { - ...state, - categories: [ - ...state.categories.slice(0, pinnedCategoryIdx), - { - ...action.payload, - bookmarks: [...state.categories[pinnedCategoryIdx].bookmarks], - }, - ...state.categories.slice(pinnedCategoryIdx + 1), - ], - }; - - case ActionType.deleteCategory: - const deletedCategoryIdx = state.categories.findIndex( - (category) => category.id === action.payload - ); - - return { - ...state, - categories: [ - ...state.categories.slice(0, deletedCategoryIdx), - ...state.categories.slice(deletedCategoryIdx + 1), - ], - }; - - case ActionType.updateCategory: - const updatedCategoryIdx = state.categories.findIndex( - (category) => category.id === action.payload.id - ); - - return { - ...state, - categories: [ - ...state.categories.slice(0, updatedCategoryIdx), - { - ...action.payload, - bookmarks: [...state.categories[updatedCategoryIdx].bookmarks], - }, - ...state.categories.slice(updatedCategoryIdx + 1), - ], - }; - - case ActionType.deleteBookmark: - const categoryInUpdateIdx = state.categories.findIndex( + case ActionType.sortBookmarks: { + const categoryIdx = state.categories.findIndex( (category) => category.id === action.payload.categoryId ); - return { - ...state, - categories: [ - ...state.categories.slice(0, categoryInUpdateIdx), - { - ...state.categories[categoryInUpdateIdx], - bookmarks: state.categories[categoryInUpdateIdx].bookmarks.filter( - (bookmark) => bookmark.id !== action.payload.bookmarkId - ), - }, - ...state.categories.slice(categoryInUpdateIdx + 1), - ], - }; - - case ActionType.updateBookmark: - const parentCategoryIdx = state.categories.findIndex( - (category) => category.id === action.payload.categoryId + const sortedBookmarks = sortData<Bookmark>( + state.categories[categoryIdx].bookmarks, + action.payload.orderType ); - const updatedBookmarkIdx = state.categories[ - parentCategoryIdx - ].bookmarks.findIndex((bookmark) => bookmark.id === action.payload.id); return { ...state, categories: [ - ...state.categories.slice(0, parentCategoryIdx), + ...state.categories.slice(0, categoryIdx), { - ...state.categories[parentCategoryIdx], - bookmarks: [ - ...state.categories[parentCategoryIdx].bookmarks.slice( - 0, - updatedBookmarkIdx - ), - action.payload, - ...state.categories[parentCategoryIdx].bookmarks.slice( - updatedBookmarkIdx + 1 - ), - ], + ...state.categories[categoryIdx], + bookmarks: sortedBookmarks, }, - ...state.categories.slice(parentCategoryIdx + 1), + ...state.categories.slice(categoryIdx + 1), ], }; + } - case ActionType.sortCategories: - return { - ...state, - categories: sortData<Category>(state.categories, action.payload), - }; - - case ActionType.reorderCategories: - return { - ...state, - categories: action.payload, - }; default: return state; } diff --git a/client/src/store/reducers/config.ts b/client/src/store/reducers/config.ts index 41c57d1..7976919 100644 --- a/client/src/store/reducers/config.ts +++ b/client/src/store/reducers/config.ts @@ -26,26 +26,31 @@ export const configReducer = ( loading: false, config: action.payload, }; + case ActionType.updateConfig: return { ...state, config: action.payload, }; + case ActionType.fetchQueries: return { ...state, customQueries: action.payload, }; + case ActionType.addQuery: return { ...state, customQueries: [...state.customQueries, action.payload], }; + case ActionType.deleteQuery: return { ...state, customQueries: action.payload, }; + case ActionType.updateQuery: return { ...state, diff --git a/client/src/store/reducers/notification.ts b/client/src/store/reducers/notification.ts index 544402f..23d8769 100644 --- a/client/src/store/reducers/notification.ts +++ b/client/src/store/reducers/notification.ts @@ -29,6 +29,7 @@ export const notificationReducer = ( ], idCounter: state.idCounter + 1, }; + case ActionType.clearNotification: return { ...state, diff --git a/client/src/store/reducers/theme.ts b/client/src/store/reducers/theme.ts index ef32495..6db29fe 100644 --- a/client/src/store/reducers/theme.ts +++ b/client/src/store/reducers/theme.ts @@ -24,6 +24,7 @@ export const themeReducer = ( switch (action.type) { case ActionType.setTheme: return { theme: action.payload }; + default: return state; } diff --git a/client/src/utility/templateObjects/bookmarkTemplate.ts b/client/src/utility/templateObjects/bookmarkTemplate.ts index d2b1749..a359392 100644 --- a/client/src/utility/templateObjects/bookmarkTemplate.ts +++ b/client/src/utility/templateObjects/bookmarkTemplate.ts @@ -13,4 +13,5 @@ export const bookmarkTemplate: Bookmark = { id: -1, createdAt: new Date(), updatedAt: new Date(), + orderId: 0, }; diff --git a/client/src/utility/validators.ts b/client/src/utility/validators.ts index 361da05..df4de72 100644 --- a/client/src/utility/validators.ts +++ b/client/src/utility/validators.ts @@ -13,7 +13,7 @@ export const isUrl = (data: string): boolean => { }; export const isImage = (data: string): boolean => { - const regex = /.(jpeg|jpg|png)$/i; + const regex = /.(jpeg|jpg|png|ico)$/i; return regex.test(data); }; diff --git a/controllers/apps/getAllApps.js b/controllers/apps/getAllApps.js index 04a7585..36f4eb1 100644 --- a/controllers/apps/getAllApps.js +++ b/controllers/apps/getAllApps.js @@ -28,17 +28,15 @@ const getAllApps = asyncWrapper(async (req, res, next) => { // apps visibility const where = req.isAuthenticated ? {} : { isPublic: true }; - if (orderType == 'name') { - apps = await App.findAll({ - order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']], - where, - }); - } else { - apps = await App.findAll({ - order: [[orderType, 'ASC']], - where, - }); - } + const order = + orderType == 'name' + ? [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']] + : [[orderType, 'ASC']]; + + apps = await App.findAll({ + order, + where, + }); if (process.env.NODE_ENV === 'production') { // Set header to fetch containers info every time diff --git a/controllers/bookmarks/getAllBookmarks.js b/controllers/bookmarks/getAllBookmarks.js index aece14b..25af9d8 100644 --- a/controllers/bookmarks/getAllBookmarks.js +++ b/controllers/bookmarks/getAllBookmarks.js @@ -1,16 +1,24 @@ const asyncWrapper = require('../../middleware/asyncWrapper'); const Bookmark = require('../../models/Bookmark'); const { Sequelize } = require('sequelize'); +const loadConfig = require('../../utils/loadConfig'); // @desc Get all bookmarks // @route GET /api/bookmarks // @access Public const getAllBookmarks = asyncWrapper(async (req, res, next) => { + const { useOrdering: orderType } = await loadConfig(); + // bookmarks visibility const where = req.isAuthenticated ? {} : { isPublic: true }; + const order = + orderType == 'name' + ? [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']] + : [[orderType, 'ASC']]; + const bookmarks = await Bookmark.findAll({ - order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']], + order, where, }); diff --git a/controllers/bookmarks/index.js b/controllers/bookmarks/index.js index f1ef588..5c1bd86 100644 --- a/controllers/bookmarks/index.js +++ b/controllers/bookmarks/index.js @@ -4,4 +4,5 @@ module.exports = { getSingleBookmark: require('./getSingleBookmark'), updateBookmark: require('./updateBookmark'), deleteBookmark: require('./deleteBookmark'), + reorderBookmarks: require('./reorderBookmarks'), }; diff --git a/controllers/bookmarks/reorderBookmarks.js b/controllers/bookmarks/reorderBookmarks.js new file mode 100644 index 0000000..3ea1c35 --- /dev/null +++ b/controllers/bookmarks/reorderBookmarks.js @@ -0,0 +1,23 @@ +const asyncWrapper = require('../../middleware/asyncWrapper'); +const Bookmark = require('../../models/Bookmark'); + +// @desc Reorder bookmarks +// @route PUT /api/bookmarks/0/reorder +// @access Public +const reorderBookmarks = asyncWrapper(async (req, res, next) => { + req.body.bookmarks.forEach(async ({ id, orderId }) => { + await Bookmark.update( + { orderId }, + { + where: { id }, + } + ); + }); + + res.status(200).json({ + success: true, + data: {}, + }); +}); + +module.exports = reorderBookmarks; diff --git a/controllers/categories/getAllCategories.js b/controllers/categories/getAllCategories.js index c42db2d..7bde6ba 100644 --- a/controllers/categories/getAllCategories.js +++ b/controllers/categories/getAllCategories.js @@ -16,29 +16,27 @@ const getAllCategories = asyncWrapper(async (req, res, next) => { // categories visibility const where = req.isAuthenticated ? {} : { isPublic: true }; - if (orderType == 'name') { - categories = await Category.findAll({ - include: [ - { - model: Bookmark, - as: 'bookmarks', - }, - ], - order: [[Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC']], - where, - }); - } else { - categories = await Category.findAll({ - include: [ - { - model: Bookmark, - as: 'bookmarks', - }, - ], - order: [[orderType, 'ASC']], - where, - }); - } + const order = + orderType == 'name' + ? [ + [Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC'], + [Sequelize.fn('lower', Sequelize.col('bookmarks.name')), 'ASC'], + ] + : [ + [orderType, 'ASC'], + [{ model: Bookmark, as: 'bookmarks' }, orderType, 'ASC'], + ]; + + categories = categories = await Category.findAll({ + include: [ + { + model: Bookmark, + as: 'bookmarks', + }, + ], + order, + where, + }); if (req.isAuthenticated) { output = categories; diff --git a/controllers/categories/getSingleCategory.js b/controllers/categories/getSingleCategory.js index 8eb5fb2..c854627 100644 --- a/controllers/categories/getSingleCategory.js +++ b/controllers/categories/getSingleCategory.js @@ -2,13 +2,22 @@ const asyncWrapper = require('../../middleware/asyncWrapper'); const ErrorResponse = require('../../utils/ErrorResponse'); const Category = require('../../models/Category'); const Bookmark = require('../../models/Bookmark'); +const { Sequelize } = require('sequelize'); +const loadConfig = require('../../utils/loadConfig'); // @desc Get single category // @route GET /api/categories/:id // @access Public const getSingleCategory = asyncWrapper(async (req, res, next) => { + const { useOrdering: orderType } = await loadConfig(); + const visibility = req.isAuthenticated ? {} : { isPublic: true }; + const order = + orderType == 'name' + ? [[Sequelize.fn('lower', Sequelize.col('bookmarks.name')), 'ASC']] + : [[{ model: Bookmark, as: 'bookmarks' }, orderType, 'ASC']]; + const category = await Category.findOne({ where: { id: req.params.id, ...visibility }, include: [ @@ -18,6 +27,7 @@ const getSingleCategory = asyncWrapper(async (req, res, next) => { where: visibility, }, ], + order, }); if (!category) { diff --git a/controllers/categories/reorderCategories.js b/controllers/categories/reorderCategories.js index 492675b..8922125 100644 --- a/controllers/categories/reorderCategories.js +++ b/controllers/categories/reorderCategories.js @@ -1,5 +1,6 @@ const asyncWrapper = require('../../middleware/asyncWrapper'); const Category = require('../../models/Category'); + // @desc Reorder categories // @route PUT /api/categories/0/reorder // @access Public diff --git a/db/migrations/04_bookmarks-order.js b/db/migrations/04_bookmarks-order.js new file mode 100644 index 0000000..26ddd69 --- /dev/null +++ b/db/migrations/04_bookmarks-order.js @@ -0,0 +1,19 @@ +const { DataTypes } = require('sequelize'); +const { INTEGER } = DataTypes; + +const up = async (query) => { + await query.addColumn('bookmarks', 'orderId', { + type: INTEGER, + allowNull: true, + defaultValue: null, + }); +}; + +const down = async (query) => { + await query.removeColumn('bookmarks', 'orderId'); +}; + +module.exports = { + up, + down, +}; diff --git a/middleware/multer.js b/middleware/multer.js index 806e5b4..cf5a384 100644 --- a/middleware/multer.js +++ b/middleware/multer.js @@ -14,7 +14,7 @@ const storage = multer.diskStorage({ }, }); -const supportedTypes = ['jpg', 'jpeg', 'png', 'svg', 'svg+xml']; +const supportedTypes = ['jpg', 'jpeg', 'png', 'svg', 'svg+xml', 'x-icon']; const fileFilter = (req, file, cb) => { if (supportedTypes.includes(file.mimetype.split('/')[1])) { diff --git a/models/Bookmark.js b/models/Bookmark.js index 159ea28..197a632 100644 --- a/models/Bookmark.js +++ b/models/Bookmark.js @@ -25,6 +25,11 @@ const Bookmark = sequelize.define( allowNull: true, defaultValue: 1, }, + orderId: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: null, + }, }, { tableName: 'bookmarks', diff --git a/routes/bookmark.js b/routes/bookmark.js index ea1a344..6bc96ad 100644 --- a/routes/bookmark.js +++ b/routes/bookmark.js @@ -10,6 +10,7 @@ const { getSingleBookmark, updateBookmark, deleteBookmark, + reorderBookmarks, } = require('../controllers/bookmarks'); router @@ -23,4 +24,6 @@ router .put(auth, requireAuth, upload, updateBookmark) .delete(auth, requireAuth, deleteBookmark); +router.route('/0/reorder').put(auth, requireAuth, reorderBookmarks); + module.exports = router;