diff --git a/.docker/Dockerfile b/.docker/Dockerfile index a0e1488..701547e 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14 as builder +FROM node:16 as builder WORKDIR /app @@ -16,7 +16,7 @@ RUN mkdir -p ./public ./data \ && mv ./client/build/* ./public \ && rm -rf ./client -FROM node:14-alpine +FROM node:16-alpine COPY --from=builder /app /app diff --git a/.docker/Dockerfile.multiarch b/.docker/Dockerfile.multiarch index 6d4c34a..289308e 100644 --- a/.docker/Dockerfile.multiarch +++ b/.docker/Dockerfile.multiarch @@ -1,4 +1,4 @@ -FROM node:14-alpine3.11 as builder +FROM node:16-alpine3.11 as builder WORKDIR /app @@ -17,7 +17,7 @@ RUN mkdir -p ./public ./data \ && mv ./client/build/* ./public \ && rm -rf ./client -FROM node:14-alpine3.11 +FROM node:16-alpine3.11 COPY --from=builder /app /app 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..9d63c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### v2.1.1 (TBA) +- Changed some messages and buttons to make it easier to open bookmarks editor ([#239](https://github.com/pawelmalak/flame/issues/239)) + +### 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 ( + + {/* 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 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(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 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 = ( -
- {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 -

- ); - } + 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) { + 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 ee82144..003e1a7 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,19 @@ 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 { Message, 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 +31,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 +62,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 +135,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 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(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 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..d306655 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx @@ -1,28 +1,52 @@ 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 { config } = useSelector((state: State) => state.config); + const { category, fromHomepage = false } = props; + + const { + config: { config }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { setEditCategory } = bindActionCreators(actionCreators, dispatch); return (
-

{props.category.name}

+

{ + if (!fromHomepage && isAuthenticated) { + 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 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 = ( -

- 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.module.css b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css deleted file mode 100644 index 8b1e0ed..0000000 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.module.css +++ /dev/null @@ -1,29 +0,0 @@ -.TableActions { - display: flex; - align-items: center; -} - -.TableAction { - width: 22px; -} - -.TableAction:hover { - cursor: pointer; -} - -.Message { - width: 100%; - display: flex; - justify-content: center; - align-items: baseline; - color: var(--color-primary); - margin-bottom: 20px; -} - -.Message a { - color: var(--color-accent); -} - -.Message a:hover { - cursor: pointer; -} \ No newline at end of file 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([]); - 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 62a2e15..0905711 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/client/src/components/Bookmarks/Bookmarks.tsx @@ -14,15 +14,19 @@ import { Category, Bookmark } from '../../interfaces'; import classes from './Bookmarks.module.css'; // UI -import { Container, Headline, ActionButton, Spinner, Modal } from '../UI'; +import { + Container, + Headline, + ActionButton, + Spinner, + Modal, + Message, +} 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,74 +38,99 @@ 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); + 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); + setEditCategory(data); } else { - setIsInEdit(true); + setFormContentType(ContentType.bookmark); + 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); } }; - 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); - } else { - setFormContentType(ContentType.bookmark); - setBookmarkInUpdate(data); - } - toggleModal(); + const finishEditing = () => { + setShowTable(false); + setEditCategory(null); }; return ( @@ -111,8 +140,6 @@ export const Bookmarks = (props: Props): JSX.Element => { modalHandler={toggleModal} contentType={formContentType} inUpdate={isInUpdate} - category={categoryInUpdate} - bookmark={bookmarkInUpdate} /> @@ -123,35 +150,44 @@ export const Bookmarks = (props: Props): JSX.Element => { addActionHandler(ContentType.category)} + handler={() => openFormForAdding(ContentType.category)} /> addActionHandler(ContentType.bookmark)} + handler={() => openFormForAdding(ContentType.bookmark)} /> editActionHandler(ContentType.category)} - /> - editActionHandler(ContentType.bookmark)} + handler={() => showTableForEditing(ContentType.category)} /> + {showTable && tableContentType === ContentType.bookmark && ( + + )}
)} + {categories.length && isAuthenticated && !showTable ? ( + + Click on category name to edit its bookmarks + + ) : ( + <> + )} + {loading ? ( - ) : !isInEdit ? ( + ) : !showTable ? ( ) : ( - )} 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 ( + {/* 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 0000000..86f0db0 --- /dev/null +++ b/client/src/components/Bookmarks/Table/BookmarksTable.tsx @@ -0,0 +1,188 @@ +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'; + +// UI +import { Message, 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 0000000..d909cab --- /dev/null +++ b/client/src/components/Bookmarks/Table/CategoryTable.tsx @@ -0,0 +1,166 @@ +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'; + +// UI +import { Message, 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/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 ? ( + + ) : ( + + ); + + 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 => {
- {!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 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(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( @@ -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 */} - +