diff --git a/.env b/.env index 9f1bd8027ee23fbe11ea2bcfcddbad4bb518a3bc..db3637dead7fbee5f086e7cc6ad712268e0ce751 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 85435345b6b07ee546519434be5bb28706074980..329b2dc0fb542dab25cd1d96ba168ea7392b443c 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 aac1ed1ae2f7de46b9a14a3fd708a30661c741cc..d6fe0e5a218969c1e6a1c6dcfdabb6128c2301c8 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 0000000000000000000000000000000000000000..69028a9b6b8c73d0e0e917ce9ca7a129eafd8cec --- /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 0000000000000000000000000000000000000000..6d9460c9862532a564fb66ab0c9e661089639a84 --- /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 ( + + {/* DELETE */} +
deleteHandler(entity.id, entity.name)} + tabIndex={0} + > + +
+ + {/* UPDATE */} +
updateHandler(entity.id)} + tabIndex={0} + > + +
+ + {/* PIN */} + {showPin && ( +
_pinHandler(entity.id)} + tabIndex={0} + > + {entity.isPinned ? ( + + ) : ( + + )} +
+ )} + + {/* VISIBILITY */} +
changeVisibilty(entity.id)} + tabIndex={0} + > + {entity.isPublic ? ( + + ) : ( + + )} +
+ + ); +}; diff --git a/client/src/components/Apps/AppForm/AppForm.tsx b/client/src/components/Apps/AppForm/AppForm.tsx index 0b94465c7c967b2bdd11d56dbff67ef311e4c0d5..8679f82e9d86cbba70773a053b4a80b09ed38767 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(false); const [customIcon, setCustomIcon] = useState(null); const [formData, setFormData] = useState(newAppTemplate); useEffect(() => { - if (app) { + if (appInUpdate) { setFormData({ - ...app, + ...appInUpdate, }); } else { setFormData(newAppTemplate); } - }, [app]); + }, [appInUpdate]); const inputChangeHandler = ( e: ChangeEvent, @@ -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" /> { @@ -182,7 +188,7 @@ export const AppForm = ({ app, modalHandler }: Props): JSX.Element => { - {!app ? ( + {!appInUpdate ? ( ) : ( diff --git a/client/src/components/Apps/AppGrid/AppGrid.module.css b/client/src/components/Apps/AppGrid/AppGrid.module.css index 7874918302fe9b61713ed5532337537361de7d73..daff4417985e6a437cd851fab16634ccd0f1d543 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 6b02443ae392561b481beb1f2cb5ca5b4274b5bd..b4cfb4ed88cf76e508d3045459d31dff8e8a62ea 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 = ( -
- {props.apps.map((app: App): JSX.Element => { - return ; - })} -
- ); + if (props.searching || props.apps.length) { + if (!props.apps.length) { + apps = No apps match your search criteria; + } else { + apps = ( +
+ {props.apps.map((app: App): JSX.Element => { + return ; + })} +
+ ); + } } else { if (props.totalApps) { - if (props.searching) { - apps = ( -

- No apps match your search criteria -

- ); - } else { - apps = ( -

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

- ); - } + apps = ( + + There are no pinned applications. You can pin them from the{' '} + /applications menu + + ); } else { apps = ( -

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

+ ); } } diff --git a/client/src/components/Apps/AppTable/AppTable.tsx b/client/src/components/Apps/AppTable/AppTable.tsx index ee821447070fe87efb418e16e23ab2c8b208f74a..aa23797ffe62daae39d708ae3017cd90bf2c2691 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'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; // Typescript import { App } from '../../../interfaces'; -// CSS +// Other 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'; +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([]); - const [isCustomOrder, setIsCustomOrder] = useState(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 (
- {isCustomOrder ? ( + {config.useOrdering === 'orderId' ? (

You can drag and drop single rows to reorder application

) : (

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

)}
+ {(provided) => ( @@ -143,54 +136,15 @@ export const AppTable = (props: Props): JSX.Element => { {app.isPublic ? 'Visible' : 'Hidden'} + {!snapshot.isDragging && ( - -
deleteAppHandler(app)} - onKeyDown={(e) => - keyboardActionHandler( - e, - app, - deleteAppHandler - ) - } - tabIndex={0} - > - -
-
props.updateAppHandler(app)} - onKeyDown={(e) => - keyboardActionHandler( - e, - app, - props.updateAppHandler - ) - } - tabIndex={0} - > - -
-
pinApp(app)} - onKeyDown={(e) => - keyboardActionHandler(e, app, pinApp) - } - tabIndex={0} - > - {app.isPinned ? ( - - ) : ( - - )} -
- + )} ); diff --git a/client/src/components/Apps/Apps.tsx b/client/src/components/Apps/Apps.tsx index 9ccb0d48b48ab9ed3c662afb620ccc286fa4d07e..88db87486c7d6fe41dc54ffe82e275408a6acbfa 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(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 ( - {!isInUpdate ? ( - - ) : ( - - )} + { {isAuthenticated && (
- + { + setEditApp(null); + toggleModal(); + }} + />
)} @@ -97,10 +99,10 @@ export const Apps = (props: Props): JSX.Element => {
{loading ? ( - ) : !isInEdit ? ( + ) : !showTable ? ( ) : ( - + )}
diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.module.css index b840a42b5965ae1f7ab33794345e2ec41d7ebcb5..2fd52f0897372529ddf36b74fc14b012681509fd 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 146bf67a6610f134a4bcf5edb53562341a49377a..88914608892a0031a8e0033f544f2da0a40aa378 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 (
-

{props.category.name}

+

{ + if (!fromHomepage) { + setEditCategory(category); + } + }} + > + {category.name} +

+
- {props.category.bookmarks.map((bookmark: Bookmark) => { + {category.bookmarks.map((bookmark: Bookmark) => { const redirectUrl = urlParser(bookmark.url)[1]; let iconEl: JSX.Element = ; diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.module.css index 8c0d1abc78fccf4fcacb42b0799f9b6b4cfbf67e..9e89f3a5f5dd47de43a868a3e861f85be099a890 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 516c3b2c853234d299ea042c99a521d6b0633be9..7e26c32280774cb2c92beb7860907c3d077419e6 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 = ( -

- No bookmarks match your search criteria -

- ); + if (categories.length) { + if (searching && !categories[0].bookmarks.length) { + bookmarks = No bookmarks match your search criteria; } else { bookmarks = (
- {props.categories.map( + {categories.map( (category: Category): JSX.Element => ( - + ) )}
); } } else { - if (props.totalCategories) { + if (totalCategories) { bookmarks = ( -

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

+ ); } else { bookmarks = ( -

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

+ ); } } diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx deleted file mode 100644 index 2cc487860b0dc8876f8da91f8a847a1318848fd6..0000000000000000000000000000000000000000 --- 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([]); - const [isCustomOrder, setIsCustomOrder] = useState(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 ( - -
- {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, - }; - - return ( - - - - {!snapshot.isDragging && ( - - )} - - ); - }} - - ); - } - )} -
- {category.name} - - {category.isPublic ? 'Visible' : 'Hidden'} - -
- deleteCategoryHandler(category) - } - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - deleteCategoryHandler - ) - } - tabIndex={0} - > - -
-
- props.updateHandler(category) - } - tabIndex={0} - > - -
-
pinCategory(category)} - onKeyDown={(e) => - keyboardActionHandler( - e, - category, - pinCategory - ) - } - tabIndex={0} - > - {category.isPinned ? ( - - ) : ( - - )} -
-
- )} -
-
-
- ); - } else { - const bookmarks: { bookmark: Bookmark; categoryName: string }[] = []; - props.categories.forEach((category: Category) => { - category.bookmarks.forEach((bookmark: Bookmark) => { - bookmarks.push({ - bookmark, - categoryName: category.name, - }); - }); - }); - - return ( - - {bookmarks.map( - (bookmark: { bookmark: Bookmark; categoryName: string }) => { - return ( - - - - - - - - - ); - } - )} -
{bookmark.bookmark.name}{bookmark.bookmark.url}{bookmark.bookmark.icon}{bookmark.bookmark.isPublic ? 'Visible' : 'Hidden'}{bookmark.categoryName} -
deleteBookmarkHandler(bookmark.bookmark)} - tabIndex={0} - > - -
-
props.updateHandler(bookmark.bookmark)} - tabIndex={0} - > - -
-
- ); - } -}; diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 62a2e1597f7ed238d2754cd0709025eec9cdaf02..a70899532a0987dbceef1f007757b25b26153c2d 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(categoryTemplate); - const [bookmarkInUpdate, setBookmarkInUpdate] = - useState(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 openFormForUpdating = (data: Category | Bookmark): void => { + setIsInUpdate(true); - const instanceOfCategory = (object: any): object is Category => { - return 'bookmarks' in object; - }; + const instanceOfCategory = (object: any): object is Category => { + return 'bookmarks' in object; + }; - const goToUpdateMode = (data: Category | Bookmark): void => { - setIsInUpdate(true); 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 ( @@ -111,8 +128,6 @@ export const Bookmarks = (props: Props): JSX.Element => { modalHandler={toggleModal} contentType={formContentType} inUpdate={isInUpdate} - category={categoryInUpdate} - bookmark={bookmarkInUpdate} /> @@ -123,35 +138,34 @@ export const Bookmarks = (props: Props): JSX.Element => { addActionHandler(ContentType.category)} + handler={() => openFormForAdding(ContentType.category)} /> addActionHandler(ContentType.bookmark)} + handler={() => openFormForAdding(ContentType.bookmark)} /> editActionHandler(ContentType.category)} + handler={() => showTableForEditing(ContentType.category)} /> editActionHandler(ContentType.bookmark)} + handler={() => showTableForEditing(ContentType.bookmark)} />
)} {loading ? ( - ) : !isInEdit ? ( + ) : !showTable ? ( ) : ( - )} diff --git a/client/src/components/Bookmarks/Form/BookmarksForm.tsx b/client/src/components/Bookmarks/Form/BookmarksForm.tsx index f0a3a4370f46e59b74de4a5188ad26ac4925af61..893b3348fbf064f8fd74f4121ca6a4afbd23429f 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 ( + {/* NAME */} + {/* URL */} + {/* CATEGORY */} 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 ( @@ -33,9 +37,15 @@ export const Form = (props: Props): JSX.Element => { // form: update {contentType === ContentType.category ? ( - + ) : ( - + )} )} diff --git a/client/src/components/Bookmarks/Table/BookmarksTable.tsx b/client/src/components/Bookmarks/Table/BookmarksTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd0f447fa2e21f3111c1401d2d78872767b748b0 --- /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([]); + + // 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 ( + + {!categoryInEdit ? ( +
+

+ Switch to grid view and click on the name of category you want to + edit +

+
+ ) : ( +
+

+ Editing bookmarks from {categoryInEdit.name} category +

+
+ )} + + {categoryInEdit && ( + + + {(provided) => ( + + {localBookmarks.map((bookmark, 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 && ( + + )} + + ); + }} + + ); + })} +
{bookmark.name}{bookmark.url}{bookmark.icon} + {bookmark.isPublic ? 'Visible' : 'Hidden'} + + {categoryInEdit.name} +
+ )} +
+
+ )} +
+ ); +}; diff --git a/client/src/components/Bookmarks/Table/CategoryTable.tsx b/client/src/components/Bookmarks/Table/CategoryTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..124bd350947a747e8461f6e23b9832b4282f9d52 --- /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([]); + + // 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 ( + +
+ {config.useOrdering === 'orderId' ? ( +

You can drag and drop single rows to reorder categories

+ ) : ( +

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

+ )} +
+ + + + {(provided) => ( + + {localCategories.map((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 && ( + + )} + + ); + }} + + ); + })} +
{category.name} + {category.isPublic ? 'Visible' : 'Hidden'} +
+ )} +
+
+
+ ); +}; 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 8b1e0edca964f2659dba2de43c04ebb5e6515d53..89ff6caff1e110c495ce47ccc6a341b004485f10 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 0000000000000000000000000000000000000000..8704fdbd043fb5f39d5c5571d3d79a0884898376 --- /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 ? ( + + ) : ( + + ); + + return tableEl; +}; diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index d7f387284f5491a5e5bc9e50aedd39b0e56a3a0c..e24e5c0ed7e492e7a042c64de9ac40db05c68f60 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 => {
- {!config.hideApps ? ( + {!isAuthenticated && + !apps.some((a) => a.isPinned) && + !categories.some((c) => c.isPinned) ? ( + + Welcome to Flame! Go to /settings, + login and start customizing your new homepage + + ) : ( + <> + )} + + {!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? ( {appsLoading ? ( @@ -119,10 +131,11 @@ export const Home = (): JSX.Element => {
) : ( -
+ <> )} - {!config.hideCategories ? ( + {!config.hideCategories && + (isAuthenticated || categories.some((c) => c.isPinned)) ? ( {bookmarksLoading ? ( @@ -138,11 +151,12 @@ export const Home = (): JSX.Element => { } totalCategories={categories.length} searching={!!localSearch} + fromHomepage={true} /> )} ) : ( -
+ <> )} diff --git a/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx index 2742d76eb75a32b182b061ef0e7a017db70b20df..d0f646656ca1f369e9a9a129ad2fe8e2ce58797a 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(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 b0de4529fd66cb78d882200e3416d3fb8c34e787..075a42709be43868f2bce87de48ab4e305a3e925 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( @@ -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 => { {/* HIDE HEADER */} - +