commit
41a3f5dae3
43 changed files with 833 additions and 137 deletions
0
._env
0
._env
2
.env
Normal file
2
.env
Normal file
|
@ -0,0 +1,2 @@
|
|||
PORT=5005
|
||||
NODE_ENV=development
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,2 @@
|
|||
node_modules/
|
||||
data/
|
||||
.env
|
||||
data/
|
26
README.md
26
README.md
|
@ -8,7 +8,7 @@
|
|||
![Homescreen screenshot](./github/_home.png)
|
||||
|
||||
## Description
|
||||
Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui)
|
||||
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own appliaction hub in no time - no file editing necessary.
|
||||
|
||||
## Technology
|
||||
- Backend
|
||||
|
@ -23,6 +23,7 @@ Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI]
|
|||
|
||||
## Development
|
||||
```sh
|
||||
# clone repository
|
||||
git clone https://github.com/pawelmalak/flame
|
||||
cd flame
|
||||
|
||||
|
@ -33,13 +34,23 @@ npm run dev-init
|
|||
npm run dev
|
||||
```
|
||||
|
||||
## Deployment with Docker
|
||||
## Building Docker images
|
||||
```sh
|
||||
# build image
|
||||
# build image for amd64 only
|
||||
docker build -t flame .
|
||||
|
||||
# build multiarch image for amd64, armv7 and arm64
|
||||
# building failed multiple times with 2GB memory usage limit so you might want to increase it
|
||||
docker buildx build \
|
||||
--platform linux/arm/v7,linux/arm64,linux/amd64 \
|
||||
-f Dockerfile.multiarch \
|
||||
-t flame:multiarch .
|
||||
```
|
||||
|
||||
## Deployment with Docker
|
||||
```sh
|
||||
# run container
|
||||
docker run -p 5005:5005 -v <host_dir>:/app/data flame
|
||||
docker run -p 5005:5005 -v /path/to/data:/app/data flame
|
||||
```
|
||||
|
||||
## Functionality
|
||||
|
@ -73,4 +84,9 @@ docker run -p 5005:5005 -v <host_dir>:/app/data flame
|
|||
- Redirect: `https://{dest}`
|
||||
- URL without protocol
|
||||
- Format: `www.domain.com`, `domain.com`, `sub.domain.com`, `local`, `ip`, `ip:port`
|
||||
- Redirect: `http://{dest}`
|
||||
- Redirect: `http://{dest}`
|
||||
|
||||
## Support
|
||||
If you want to support development of Flame and my upcoming self-hosted and open source projects you can use the following link:
|
||||
|
||||
[![PayPal Badge](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.com/paypalme/pawelmalak)
|
|
@ -1 +1 @@
|
|||
REACT_APP_VERSION=1.3.1
|
||||
REACT_APP_VERSION=1.4.0
|
45
client/package-lock.json
generated
45
client/package-lock.json
generated
|
@ -2397,6 +2397,14 @@
|
|||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/react-beautiful-dnd": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz",
|
||||
"integrity": "sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==",
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "17.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
|
||||
|
@ -4614,6 +4622,14 @@
|
|||
"postcss": "^7.0.5"
|
||||
}
|
||||
},
|
||||
"css-box-model": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||
"requires": {
|
||||
"tiny-invariant": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"css-color-names": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
|
||||
|
@ -9932,6 +9948,11 @@
|
|||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
|
||||
},
|
||||
"memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||
},
|
||||
"memory-fs": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
||||
|
@ -12300,6 +12321,11 @@
|
|||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
|
||||
},
|
||||
"randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
|
@ -12362,6 +12388,20 @@
|
|||
"whatwg-fetch": "^3.4.1"
|
||||
}
|
||||
},
|
||||
"react-beautiful-dnd": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz",
|
||||
"integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"css-box-model": "^1.2.0",
|
||||
"memoize-one": "^5.1.1",
|
||||
"raf-schd": "^4.0.2",
|
||||
"react-redux": "^7.2.0",
|
||||
"redux": "^4.0.4",
|
||||
"use-memo-one": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"react-dev-utils": {
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
|
||||
|
@ -15077,6 +15117,11 @@
|
|||
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
||||
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
|
||||
},
|
||||
"use-memo-one": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz",
|
||||
"integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ=="
|
||||
},
|
||||
"util": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
|
||||
|
|
|
@ -11,12 +11,14 @@
|
|||
"@types/jest": "^26.0.23",
|
||||
"@types/node": "^12.20.12",
|
||||
"@types/react": "^17.0.5",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"axios": "^0.21.1",
|
||||
"http-proxy-middleware": "^2.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-router-dom": "^5.2.0",
|
||||
|
|
|
@ -5,6 +5,10 @@ import { getConfig, setTheme } from './store/actions';
|
|||
import { store } from './store/store';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// Utils
|
||||
import { checkVersion } from './utility';
|
||||
|
||||
// Routes
|
||||
import Home from './components/Home/Home';
|
||||
import Apps from './components/Apps/Apps';
|
||||
import Settings from './components/Settings/Settings';
|
||||
|
@ -14,10 +18,14 @@ import NotificationCenter from './components/NotificationCenter/NotificationCent
|
|||
// Get config pairs from database
|
||||
store.dispatch<any>(getConfig());
|
||||
|
||||
// Set theme
|
||||
if (localStorage.theme) {
|
||||
store.dispatch<any>(setTheme(localStorage.theme));
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
checkVersion();
|
||||
|
||||
const App = (): JSX.Element => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
|
|
|
@ -9,4 +9,21 @@
|
|||
|
||||
.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;
|
||||
}
|
|
@ -1,20 +1,52 @@
|
|||
import { KeyboardEvent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { App, GlobalState } from '../../../interfaces';
|
||||
import { pinApp, deleteApp } from '../../../store/actions';
|
||||
import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { pinApp, deleteApp, reorderApps, updateConfig, createNotification } from '../../../store/actions';
|
||||
|
||||
// Typescript
|
||||
import { App, GlobalState, NewNotification } from '../../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './AppTable.module.css';
|
||||
|
||||
// UI
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import Table from '../../UI/Table/Table';
|
||||
|
||||
// Utils
|
||||
import { searchConfig } from '../../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
apps: App[];
|
||||
pinApp: (app: App) => void;
|
||||
deleteApp: (id: number) => void;
|
||||
updateAppHandler: (app: App) => void;
|
||||
reorderApps: (apps: App[]) => void;
|
||||
updateConfig: (formData: any) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
|
||||
const AppTable = (props: ComponentProps): JSX.Element => {
|
||||
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
|
||||
|
||||
// Copy apps array
|
||||
useEffect(() => {
|
||||
setLocalApps([...props.apps]);
|
||||
}, [props.apps])
|
||||
|
||||
// Check ordering
|
||||
useEffect(() => {
|
||||
const order = searchConfig('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} ?`);
|
||||
|
||||
|
@ -23,55 +55,111 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
props.createNotification({
|
||||
title: 'Error',
|
||||
message: 'Custom order is disabled'
|
||||
})
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpApps = [...localApps];
|
||||
const [movedApp] = tmpApps.splice(result.source.index, 1);
|
||||
tmpApps.splice(result.destination.index, 0, movedApp);
|
||||
|
||||
setLocalApps(tmpApps);
|
||||
props.reorderApps(tmpApps);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table headers={[
|
||||
'Name',
|
||||
'URL',
|
||||
'Icon',
|
||||
'Actions'
|
||||
]}>
|
||||
{props.apps.map((app: App): JSX.Element => {
|
||||
return (
|
||||
<tr key={app.id}>
|
||||
<td>{app.name}</td>
|
||||
<td>{app.url}</td>
|
||||
<td>{app.icon}</td>
|
||||
<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={() => props.pinApp(app)}
|
||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
|
||||
tabIndex={0}>
|
||||
{app.isPinned
|
||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
||||
: <Icon icon='mdiPin' />
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</Table>
|
||||
<Fragment>
|
||||
<div className={classes.Message}>
|
||||
{isCustomOrder
|
||||
? <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></p>
|
||||
}
|
||||
</div>
|
||||
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||
<Droppable droppableId='apps'>
|
||||
{(provided) => (
|
||||
<Table headers={[
|
||||
'Name',
|
||||
'URL',
|
||||
'Icon',
|
||||
'Actions'
|
||||
]}
|
||||
innerRef={provided.innerRef}>
|
||||
{localApps.map((app: App, index): JSX.Element => {
|
||||
return (
|
||||
<Draggable key={app.id} draggableId={app.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' }}>{app.name}</td>
|
||||
<td style={{ width:'200px' }}>{app.url}</td>
|
||||
<td style={{ width:'200px' }}>{app.icon}</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={() => props.pinApp(app)}
|
||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
|
||||
tabIndex={0}>
|
||||
{app.isPinned
|
||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
||||
: <Icon icon='mdiPin' />
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</Draggable>
|
||||
)
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -81,4 +169,12 @@ const mapStateToProps = (state: GlobalState) => {
|
|||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { pinApp, deleteApp })(AppTable);
|
||||
const actions = {
|
||||
pinApp,
|
||||
deleteApp,
|
||||
reorderApps,
|
||||
updateConfig,
|
||||
createNotification
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, actions)(AppTable);
|
|
@ -44,6 +44,7 @@ const Apps = (props: ComponentProps): JSX.Element => {
|
|||
url: 'string',
|
||||
icon: 'string',
|
||||
isPinned: false,
|
||||
orderId: 0,
|
||||
id: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
|
|
|
@ -9,4 +9,21 @@
|
|||
|
||||
.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;
|
||||
}
|
|
@ -1,13 +1,25 @@
|
|||
import { ContentType } from '../Bookmarks';
|
||||
import classes from './BookmarkTable.module.css';
|
||||
import { connect } from 'react-redux';
|
||||
import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions';
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { KeyboardEvent, useState, useEffect, Fragment } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { pinCategory, deleteCategory, deleteBookmark, createNotification, reorderCategories } from '../../../store/actions';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category, NewNotification } from '../../../interfaces';
|
||||
import { ContentType } from '../Bookmarks';
|
||||
|
||||
// CSS
|
||||
import classes from './BookmarkTable.module.css';
|
||||
|
||||
// UI
|
||||
import Table from '../../UI/Table/Table';
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
|
||||
// Utils
|
||||
import { searchConfig } from '../../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
|
@ -15,9 +27,28 @@ interface ComponentProps {
|
|||
deleteCategory: (id: number) => void;
|
||||
updateHandler: (data: Category | Bookmark) => void;
|
||||
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
reorderCategories: (categories: Category[]) => void;
|
||||
}
|
||||
|
||||
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
||||
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 = searchConfig('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`);
|
||||
|
||||
|
@ -40,46 +71,100 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
}
|
||||
}
|
||||
|
||||
const dragEndHanlder = (result: DropResult): void => {
|
||||
if (!isCustomOrder) {
|
||||
props.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);
|
||||
props.reorderCategories(tmpCategories);
|
||||
}
|
||||
|
||||
if (props.contentType === ContentType.category) {
|
||||
return (
|
||||
<Table headers={[
|
||||
'Name',
|
||||
'Actions'
|
||||
]}>
|
||||
{props.categories.map((category: Category) => {
|
||||
return (
|
||||
<tr key={category.id}>
|
||||
<td>{category.name}</td>
|
||||
<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)}
|
||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
||||
tabIndex={0}>
|
||||
<Icon icon='mdiPencil' />
|
||||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.pinCategory(category)}
|
||||
onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)}
|
||||
tabIndex={0}>
|
||||
{category.isPinned
|
||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
||||
: <Icon icon='mdiPin' />
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</Table>
|
||||
<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',
|
||||
'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>{category.name}</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={() => props.pinCategory(category)}
|
||||
onKeyDown={(e) => keyboardActionHandler(e, category, props.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}[] = [];
|
||||
|
@ -111,14 +196,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
||||
// onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
||||
tabIndex={0}>
|
||||
<Icon icon='mdiDelete' />
|
||||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.updateHandler(bookmark.bookmark)}
|
||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
||||
tabIndex={0}>
|
||||
<Icon icon='mdiPencil' />
|
||||
</div>
|
||||
|
@ -131,4 +214,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
}
|
||||
}
|
||||
|
||||
export default connect(null, { pinCategory, deleteCategory, deleteBookmark })(BookmarkTable);
|
||||
const actions = {
|
||||
pinCategory,
|
||||
deleteCategory,
|
||||
deleteBookmark,
|
||||
createNotification,
|
||||
reorderCategories
|
||||
}
|
||||
|
||||
export default connect(null, actions)(BookmarkTable);
|
|
@ -43,6 +43,7 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||
name: '',
|
||||
id: -1,
|
||||
isPinned: false,
|
||||
orderId: 0,
|
||||
bookmarks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.AppVersion {
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.AppVersion a {
|
||||
color: var(--color-accent);
|
||||
}
|
25
client/src/components/Settings/AppDetails/AppDetails.tsx
Normal file
25
client/src/components/Settings/AppDetails/AppDetails.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
import classes from './AppDetails.module.css';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import { checkVersion } from '../../../utility';
|
||||
|
||||
const AppDetails = (): JSX.Element => {
|
||||
return (
|
||||
<Fragment>
|
||||
<p className={classes.AppVersion}>
|
||||
<a
|
||||
href='https://github.com/pawelmalak/flame'
|
||||
target='_blank'
|
||||
rel='noreferrer'>
|
||||
Flame
|
||||
</a>
|
||||
{' '}
|
||||
version {process.env.REACT_APP_VERSION}
|
||||
</p>
|
||||
<Button click={() => checkVersion(true)}>Check for updates</Button>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppDetails;
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
|||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification, updateConfig } from '../../../store/actions';
|
||||
import { createNotification, updateConfig, sortApps, sortCategories } from '../../../store/actions';
|
||||
|
||||
// Typescript
|
||||
import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces';
|
||||
|
@ -17,6 +17,8 @@ import { searchConfig } from '../../../utility';
|
|||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
updateConfig: (formData: SettingsForm) => void;
|
||||
sortApps: () => void;
|
||||
sortCategories: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
|
@ -26,7 +28,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||
customTitle: document.title,
|
||||
pinAppsByDefault: 1,
|
||||
pinCategoriesByDefault: 1,
|
||||
hideHeader: 0
|
||||
hideHeader: 0,
|
||||
useOrdering: 'createdAt'
|
||||
})
|
||||
|
||||
// Get config
|
||||
|
@ -35,7 +38,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||
customTitle: searchConfig('customTitle', 'Flame'),
|
||||
pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
|
||||
pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
|
||||
hideHeader: searchConfig('hideHeader', 0)
|
||||
hideHeader: searchConfig('hideHeader', 0),
|
||||
useOrdering: searchConfig('useOrdering', 'createdAt')
|
||||
})
|
||||
}, [props.loading]);
|
||||
|
||||
|
@ -46,8 +50,12 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||
// Save settings
|
||||
await props.updateConfig(formData);
|
||||
|
||||
// update local page title
|
||||
// Update local page title
|
||||
document.title = formData.customTitle;
|
||||
|
||||
// Sort apps and categories with new settings
|
||||
props.sortApps();
|
||||
props.sortCategories();
|
||||
}
|
||||
|
||||
// Input handler
|
||||
|
@ -113,6 +121,19 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor='useOrdering'>Sorting type</label>
|
||||
<select
|
||||
id='useOrdering'
|
||||
name='useOrdering'
|
||||
value={formData.useOrdering}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
>
|
||||
<option value='createdAt'>By creation date</option>
|
||||
<option value='name'>Alphabetical order</option>
|
||||
<option value='orderId'>Custom order</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
)
|
||||
|
@ -124,4 +145,11 @@ const mapStateToProps = (state: GlobalState) => {
|
|||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { createNotification, updateConfig })(OtherSettings);
|
||||
const actions = {
|
||||
createNotification,
|
||||
updateConfig,
|
||||
sortApps,
|
||||
sortCategories
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, actions)(OtherSettings);
|
|
@ -1,12 +1,14 @@
|
|||
import { NavLink, Link, Switch, Route, withRouter } from 'react-router-dom';
|
||||
import { NavLink, Link, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import classes from './Settings.module.css';
|
||||
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
|
||||
import Themer from '../Themer/Themer';
|
||||
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
||||
import OtherSettings from './OtherSettings/OtherSettings';
|
||||
import AppDetails from './AppDetails/AppDetails';
|
||||
|
||||
const Settings = (): JSX.Element => {
|
||||
return (
|
||||
|
@ -38,12 +40,20 @@ const Settings = (): JSX.Element => {
|
|||
to='/settings/other'>
|
||||
Other
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={classes.SettingsNavLink}
|
||||
activeClassName={classes.SettingsNavLinkActive}
|
||||
exact
|
||||
to='/settings/app'>
|
||||
App
|
||||
</NavLink>
|
||||
</nav>
|
||||
<section className={classes.SettingsContent}>
|
||||
<Switch>
|
||||
<Route exact path='/settings' component={Themer} />
|
||||
<Route path='/settings/weather' component={WeatherSettings} />
|
||||
<Route path='/settings/other' component={OtherSettings} />
|
||||
<Route path='/settings/app' component={AppDetails} />
|
||||
</Switch>
|
||||
</section>
|
||||
</div>
|
||||
|
@ -51,4 +61,4 @@ const Settings = (): JSX.Element => {
|
|||
)
|
||||
}
|
||||
|
||||
export default withRouter(Settings);
|
||||
export default Settings;
|
|
@ -116,6 +116,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
placeholder='52.22'
|
||||
value={formData.lat}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
step='any'
|
||||
lang='en-150'
|
||||
/>
|
||||
<span>
|
||||
You can use
|
||||
|
@ -135,6 +137,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
placeholder='21.01'
|
||||
value={formData.long}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
step='any'
|
||||
lang='en-150'
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
|
|
|
@ -6,8 +6,7 @@
|
|||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.Button:hover,
|
||||
.Button:focus {
|
||||
.Button:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-background);
|
||||
|
|
|
@ -2,10 +2,20 @@ import classes from './Button.module.css';
|
|||
|
||||
interface ComponentProps {
|
||||
children: string;
|
||||
click?: any;
|
||||
}
|
||||
|
||||
const Button = (props: ComponentProps): JSX.Element => {
|
||||
return <button className={classes.Button}>{props.children}</button>
|
||||
const {
|
||||
children,
|
||||
click
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button className={classes.Button} onClick={click ? click : () => {}} >
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button;
|
|
@ -8,15 +8,17 @@
|
|||
text-align: left;
|
||||
font-size: 16px;
|
||||
color: var(--color-primary);
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.Table th,
|
||||
.Table td {
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Head */
|
||||
|
||||
.Table th {
|
||||
--header-radius: 4px;
|
||||
background-color: var(--color-primary);
|
||||
|
@ -34,8 +36,6 @@
|
|||
}
|
||||
|
||||
/* Body */
|
||||
|
||||
.Table td {
|
||||
/* opacity: 0.5; */
|
||||
transition: all 0.2s;
|
||||
}
|
|
@ -3,11 +3,12 @@ import classes from './Table.module.css';
|
|||
interface ComponentProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
headers: string[];
|
||||
innerRef?: any;
|
||||
}
|
||||
|
||||
const Table = (props: ComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classes.TableContainer}>
|
||||
<div className={classes.TableContainer} ref={props.innerRef}>
|
||||
<table className={classes.Table}>
|
||||
<thead className={classes.TableHead}>
|
||||
<tr>
|
||||
|
|
|
@ -5,6 +5,7 @@ export interface App extends Model {
|
|||
url: string;
|
||||
icon: string;
|
||||
isPinned: boolean;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export interface NewApp {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Model, Bookmark } from '.';
|
|||
export interface Category extends Model {
|
||||
name: string;
|
||||
isPinned: boolean;
|
||||
orderId: number;
|
||||
bookmarks: Bookmark[];
|
||||
}
|
||||
|
||||
|
|
|
@ -10,4 +10,5 @@ export interface SettingsForm {
|
|||
pinAppsByDefault: number;
|
||||
pinCategoriesByDefault: number;
|
||||
hideHeader: number;
|
||||
useOrdering: string;
|
||||
}
|
|
@ -7,12 +7,16 @@ import {
|
|||
AddAppAction,
|
||||
DeleteAppAction,
|
||||
UpdateAppAction,
|
||||
ReorderAppsAction,
|
||||
SortAppsAction,
|
||||
// Categories
|
||||
GetCategoriesAction,
|
||||
AddCategoryAction,
|
||||
PinCategoryAction,
|
||||
DeleteCategoryAction,
|
||||
UpdateCategoryAction,
|
||||
SortCategoriesAction,
|
||||
ReorderCategoriesAction,
|
||||
// Bookmarks
|
||||
AddBookmarkAction,
|
||||
DeleteBookmarkAction,
|
||||
|
@ -37,6 +41,8 @@ export enum ActionTypes {
|
|||
addAppSuccess = 'ADD_APP_SUCCESS',
|
||||
deleteApp = 'DELETE_APP',
|
||||
updateApp = 'UPDATE_APP',
|
||||
reorderApps = 'REORDER_APPS',
|
||||
sortApps = 'SORT_APPS',
|
||||
// Categories
|
||||
getCategories = 'GET_CATEGORIES',
|
||||
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
|
||||
|
@ -45,6 +51,8 @@ export enum ActionTypes {
|
|||
pinCategory = 'PIN_CATEGORY',
|
||||
deleteCategory = 'DELETE_CATEGORY',
|
||||
updateCategory = 'UPDATE_CATEGORY',
|
||||
sortCategories = 'SORT_CATEGORIES',
|
||||
reorderCategories = 'REORDER_CATEGORIES',
|
||||
// Bookmarks
|
||||
addBookmark = 'ADD_BOOKMARK',
|
||||
deleteBookmark = 'DELETE_BOOKMARK',
|
||||
|
@ -66,12 +74,16 @@ export type Action =
|
|||
AddAppAction |
|
||||
DeleteAppAction |
|
||||
UpdateAppAction |
|
||||
ReorderAppsAction |
|
||||
SortAppsAction |
|
||||
// Categories
|
||||
GetCategoriesAction<any> |
|
||||
AddCategoryAction |
|
||||
PinCategoryAction |
|
||||
DeleteCategoryAction |
|
||||
UpdateCategoryAction |
|
||||
SortCategoriesAction |
|
||||
ReorderCategoriesAction |
|
||||
// Bookmarks
|
||||
AddBookmarkAction |
|
||||
DeleteBookmarkAction |
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import axios from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ActionTypes } from './actionTypes';
|
||||
import { App, ApiResponse, NewApp } from '../../interfaces';
|
||||
import { App, ApiResponse, NewApp, Config } from '../../interfaces';
|
||||
import { CreateNotificationAction } from './notification';
|
||||
|
||||
export interface GetAppsAction<T> {
|
||||
|
@ -73,10 +73,13 @@ export const addApp = (formData: NewApp) => async (dispatch: Dispatch) => {
|
|||
}
|
||||
})
|
||||
|
||||
dispatch<AddAppAction>({
|
||||
await dispatch<AddAppAction>({
|
||||
type: ActionTypes.addAppSuccess,
|
||||
payload: res.data.data
|
||||
})
|
||||
|
||||
// Sort apps
|
||||
dispatch<any>(sortApps())
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
@ -125,10 +128,63 @@ export const updateApp = (id: number, formData: NewApp) => async (dispatch: Disp
|
|||
}
|
||||
})
|
||||
|
||||
dispatch<UpdateAppAction>({
|
||||
await dispatch<UpdateAppAction>({
|
||||
type: ActionTypes.updateApp,
|
||||
payload: res.data.data
|
||||
})
|
||||
|
||||
// Sort apps
|
||||
dispatch<any>(sortApps())
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReorderAppsAction {
|
||||
type: ActionTypes.reorderApps;
|
||||
payload: App[]
|
||||
}
|
||||
|
||||
interface ReorderQuery {
|
||||
apps: {
|
||||
id: number;
|
||||
orderId: number;
|
||||
}[]
|
||||
}
|
||||
|
||||
export const reorderApps = (apps: App[]) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const updateQuery: ReorderQuery = { apps: [] }
|
||||
|
||||
apps.forEach((app, index) => updateQuery.apps.push({
|
||||
id: app.id,
|
||||
orderId: index + 1
|
||||
}))
|
||||
|
||||
await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery);
|
||||
|
||||
dispatch<ReorderAppsAction>({
|
||||
type: ActionTypes.reorderApps,
|
||||
payload: apps
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SortAppsAction {
|
||||
type: ActionTypes.sortApps;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export const sortApps = () => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<Config>>('/api/config/useOrdering');
|
||||
|
||||
dispatch<SortAppsAction>({
|
||||
type: ActionTypes.sortApps,
|
||||
payload: res.data.data.value
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import axios from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ActionTypes } from './actionTypes';
|
||||
import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark } from '../../interfaces';
|
||||
import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark, Config } from '../../interfaces';
|
||||
import { CreateNotificationAction } from './notification';
|
||||
|
||||
/**
|
||||
|
@ -54,6 +54,8 @@ export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch)
|
|||
type: ActionTypes.addCategory,
|
||||
payload: res.data.data
|
||||
})
|
||||
|
||||
dispatch<any>(sortCategories());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
@ -173,6 +175,8 @@ export const updateCategory = (id: number, formData: NewCategory) => async (disp
|
|||
type: ActionTypes.updateCategory,
|
||||
payload: res.data.data
|
||||
})
|
||||
|
||||
dispatch<any>(sortCategories());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
@ -261,4 +265,60 @@ export const updateBookmark = (bookmarkId: number, formData: NewBookmark, previo
|
|||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SORT CATEGORIES
|
||||
*/
|
||||
export interface SortCategoriesAction {
|
||||
type: ActionTypes.sortCategories;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export const sortCategories = () => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<Config>>('/api/config/useOrdering');
|
||||
|
||||
dispatch<SortCategoriesAction>({
|
||||
type: ActionTypes.sortCategories,
|
||||
payload: res.data.data.value
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REORDER CATEGORIES
|
||||
*/
|
||||
export interface ReorderCategoriesAction {
|
||||
type: ActionTypes.reorderCategories;
|
||||
payload: Category[];
|
||||
}
|
||||
|
||||
interface ReorderQuery {
|
||||
categories: {
|
||||
id: number;
|
||||
orderId: number;
|
||||
}[]
|
||||
}
|
||||
|
||||
export const reorderCategories = (categories: Category[]) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const updateQuery: ReorderQuery = { categories: [] }
|
||||
|
||||
categories.forEach((category, index) => updateQuery.categories.push({
|
||||
id: category.id,
|
||||
orderId: index + 1
|
||||
}))
|
||||
|
||||
await axios.put<ApiResponse<{}>>('/api/categories/0/reorder', updateQuery);
|
||||
|
||||
dispatch<ReorderCategoriesAction>({
|
||||
type: ActionTypes.reorderCategories,
|
||||
payload: categories
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { ActionTypes, Action } from '../actions';
|
||||
import { App } from '../../interfaces/App';
|
||||
import { sortData } from '../../utility';
|
||||
|
||||
export interface State {
|
||||
loading: boolean;
|
||||
|
@ -52,11 +53,9 @@ const pinApp = (state: State, action: Action): State => {
|
|||
}
|
||||
|
||||
const addAppSuccess = (state: State, action: Action): State => {
|
||||
const tmpApps = [...state.apps, action.payload];
|
||||
|
||||
return {
|
||||
...state,
|
||||
apps: tmpApps
|
||||
apps: [...state.apps, action.payload]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -85,6 +84,22 @@ const updateApp = (state: State, action: Action): State => {
|
|||
}
|
||||
}
|
||||
|
||||
const reorderApps = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
apps: action.payload
|
||||
}
|
||||
}
|
||||
|
||||
const sortApps = (state: State, action: Action): State => {
|
||||
const sortedApps = sortData<App>(state.apps, action.payload);
|
||||
|
||||
return {
|
||||
...state,
|
||||
apps: sortedApps
|
||||
}
|
||||
}
|
||||
|
||||
const appReducer = (state = initialState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.getApps: return getApps(state, action);
|
||||
|
@ -94,6 +109,8 @@ const appReducer = (state = initialState, action: Action) => {
|
|||
case ActionTypes.addAppSuccess: return addAppSuccess(state, action);
|
||||
case ActionTypes.deleteApp: return deleteApp(state, action);
|
||||
case ActionTypes.updateApp: return updateApp(state, action);
|
||||
case ActionTypes.reorderApps: return reorderApps(state, action);
|
||||
case ActionTypes.sortApps: return sortApps(state, action);
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ActionTypes, Action } from '../actions';
|
||||
import { Category, Bookmark } from '../../interfaces';
|
||||
import { sortData } from '../../utility';
|
||||
|
||||
export interface State {
|
||||
loading: boolean;
|
||||
|
@ -141,6 +142,22 @@ const updateBookmark = (state: State, action: Action): State => {
|
|||
}
|
||||
}
|
||||
|
||||
const sortCategories = (state: State, action: Action): State => {
|
||||
const sortedCategories = sortData<Category>(state.categories, action.payload);
|
||||
|
||||
return {
|
||||
...state,
|
||||
categories: sortedCategories
|
||||
}
|
||||
}
|
||||
|
||||
const reorderCategories = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
categories: action.payload
|
||||
}
|
||||
}
|
||||
|
||||
const bookmarkReducer = (state = initialState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.getCategories: return getCategories(state, action);
|
||||
|
@ -152,6 +169,8 @@ const bookmarkReducer = (state = initialState, action: Action) => {
|
|||
case ActionTypes.updateCategory: return updateCategory(state, action);
|
||||
case ActionTypes.deleteBookmark: return deleteBookmark(state, action);
|
||||
case ActionTypes.updateBookmark: return updateBookmark(state, action);
|
||||
case ActionTypes.sortCategories: return sortCategories(state, action);
|
||||
case ActionTypes.reorderCategories: return reorderCategories(state, action);
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
|
27
client/src/utility/checkVersion.ts
Normal file
27
client/src/utility/checkVersion.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import axios from 'axios';
|
||||
import { store } from '../store/store';
|
||||
import { createNotification } from '../store/actions';
|
||||
|
||||
export const checkVersion = async (isForced: boolean = false) => {
|
||||
try {
|
||||
const res = await axios.get<string>('https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env');
|
||||
|
||||
const githubVersion = res.data
|
||||
.split('\n')
|
||||
.map(pair => pair.split('='))[0][1];
|
||||
|
||||
if (githubVersion !== process.env.REACT_APP_VERSION) {
|
||||
store.dispatch<any>(createNotification({
|
||||
title: 'Info',
|
||||
message: 'New version is available!'
|
||||
}))
|
||||
} else if (isForced) {
|
||||
store.dispatch<any>(createNotification({
|
||||
title: 'Info',
|
||||
message: 'You are using the latest version!'
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
export * from './iconParser';
|
||||
export * from './urlParser';
|
||||
export * from './searchConfig';
|
||||
export * from './searchConfig';
|
||||
export * from './checkVersion';
|
||||
export * from './sortData';
|
|
@ -18,7 +18,7 @@ export const searchConfig = (key: string, _default: any) => {
|
|||
} else {
|
||||
return pair.value;
|
||||
}
|
||||
} else {
|
||||
return _default;
|
||||
}
|
||||
|
||||
return _default;
|
||||
}
|
29
client/src/utility/sortData.ts
Normal file
29
client/src/utility/sortData.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
interface Data {
|
||||
name: string;
|
||||
orderId: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const sortData = <T extends Data>(array: T[], field: string): T[] => {
|
||||
const sortedData = array.slice();
|
||||
|
||||
if (field === 'name') {
|
||||
sortedData.sort((a: T, b: T) => {
|
||||
return a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
|
||||
})
|
||||
} else if (field === 'orderId') {
|
||||
sortedData.sort((a: T, b: T) => {
|
||||
if (a.orderId < b.orderId) { return -1 }
|
||||
if (a.orderId > b.orderId) { return 1 }
|
||||
return 0;
|
||||
})
|
||||
} else {
|
||||
sortedData.sort((a: T, b: T) => {
|
||||
if (a.createdAt < b.createdAt) { return -1 }
|
||||
if (a.createdAt > b.createdAt) { return 1 }
|
||||
return 0;
|
||||
})
|
||||
}
|
||||
|
||||
return sortedData;
|
||||
}
|
|
@ -2,6 +2,7 @@ const asyncWrapper = require('../middleware/asyncWrapper');
|
|||
const ErrorResponse = require('../utils/ErrorResponse');
|
||||
const App = require('../models/App');
|
||||
const Config = require('../models/Config');
|
||||
const { Sequelize } = require('sequelize');
|
||||
|
||||
// @desc Create new app
|
||||
// @route POST /api/apps
|
||||
|
@ -35,10 +36,24 @@ exports.createApp = asyncWrapper(async (req, res, next) => {
|
|||
// @route GET /api/apps
|
||||
// @access Public
|
||||
exports.getApps = asyncWrapper(async (req, res, next) => {
|
||||
const apps = await App.findAll({
|
||||
order: [['name', 'ASC']]
|
||||
// Get config from database
|
||||
const useOrdering = await Config.findOne({
|
||||
where: { key: 'useOrdering' }
|
||||
});
|
||||
|
||||
const orderType = useOrdering ? useOrdering.value : 'createdAt';
|
||||
let apps;
|
||||
|
||||
if (orderType == 'name') {
|
||||
apps = await App.findAll({
|
||||
order: [[ Sequelize.fn('lower', Sequelize.col('name')), 'ASC' ]]
|
||||
});
|
||||
} else {
|
||||
apps = await App.findAll({
|
||||
order: [[ orderType, 'ASC' ]]
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: apps
|
||||
|
@ -91,6 +106,22 @@ exports.deleteApp = asyncWrapper(async (req, res, next) => {
|
|||
where: { id: req.params.id }
|
||||
})
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {}
|
||||
})
|
||||
})
|
||||
|
||||
// @desc Reorder apps
|
||||
// @route PUT /api/apps/0/reorder
|
||||
// @access Public
|
||||
exports.reorderApps = asyncWrapper(async (req, res, next) => {
|
||||
req.body.apps.forEach(async ({ id, orderId }) => {
|
||||
await App.update({ orderId }, {
|
||||
where: { id }
|
||||
})
|
||||
})
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const asyncWrapper = require('../middleware/asyncWrapper');
|
||||
const ErrorResponse = require('../utils/ErrorResponse');
|
||||
const Bookmark = require('../models/Bookmark');
|
||||
const { Sequelize } = require('sequelize');
|
||||
|
||||
// @desc Create new bookmark
|
||||
// @route POST /api/bookmarks
|
||||
|
@ -19,7 +20,7 @@ exports.createBookmark = asyncWrapper(async (req, res, next) => {
|
|||
// @access Public
|
||||
exports.getBookmarks = asyncWrapper(async (req, res, next) => {
|
||||
const bookmarks = await Bookmark.findAll({
|
||||
order: [['name', 'ASC']]
|
||||
order: [[ Sequelize.fn('lower', Sequelize.col('name')), 'ASC' ]]
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
|
|
|
@ -3,6 +3,7 @@ const ErrorResponse = require('../utils/ErrorResponse');
|
|||
const Category = require('../models/Category');
|
||||
const Bookmark = require('../models/Bookmark');
|
||||
const Config = require('../models/Config');
|
||||
const { Sequelize } = require('sequelize')
|
||||
|
||||
// @desc Create new category
|
||||
// @route POST /api/categories
|
||||
|
@ -36,14 +37,32 @@ exports.createCategory = asyncWrapper(async (req, res, next) => {
|
|||
// @route GET /api/categories
|
||||
// @access Public
|
||||
exports.getCategories = asyncWrapper(async (req, res, next) => {
|
||||
const categories = await Category.findAll({
|
||||
include: [{
|
||||
model: Bookmark,
|
||||
as: 'bookmarks'
|
||||
}],
|
||||
order: [['name', 'ASC']]
|
||||
// Get config from database
|
||||
const useOrdering = await Config.findOne({
|
||||
where: { key: 'useOrdering' }
|
||||
});
|
||||
|
||||
const orderType = useOrdering ? useOrdering.value : 'createdAt';
|
||||
let categories;
|
||||
|
||||
if (orderType == 'name') {
|
||||
categories = await Category.findAll({
|
||||
include: [{
|
||||
model: Bookmark,
|
||||
as: 'bookmarks'
|
||||
}],
|
||||
order: [[ Sequelize.fn('lower', Sequelize.col('Category.name')), 'ASC' ]]
|
||||
});
|
||||
} else {
|
||||
categories = await Category.findAll({
|
||||
include: [{
|
||||
model: Bookmark,
|
||||
as: 'bookmarks'
|
||||
}],
|
||||
order: [[ orderType, 'ASC' ]]
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: categories
|
||||
|
@ -118,6 +137,22 @@ exports.deleteCategory = asyncWrapper(async (req, res, next) => {
|
|||
where: { id: req.params.id }
|
||||
})
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {}
|
||||
})
|
||||
})
|
||||
|
||||
// @desc Reorder categories
|
||||
// @route PUT /api/categories/0/reorder
|
||||
// @access Public
|
||||
exports.reorderCategories = asyncWrapper(async (req, res, next) => {
|
||||
req.body.categories.forEach(async ({ id, orderId }) => {
|
||||
await Category.update({ orderId }, {
|
||||
where: { id }
|
||||
})
|
||||
})
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {}
|
||||
|
|
|
@ -18,6 +18,11 @@ const App = sequelize.define('App', {
|
|||
isPinned: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
orderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
}
|
||||
}, {
|
||||
tableName: 'apps'
|
||||
|
|
|
@ -9,6 +9,11 @@ const Category = sequelize.define('Category', {
|
|||
isPinned: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
orderId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
}
|
||||
}, {
|
||||
tableName: 'categories'
|
||||
|
|
|
@ -6,7 +6,8 @@ const {
|
|||
getApps,
|
||||
getApp,
|
||||
updateApp,
|
||||
deleteApp
|
||||
deleteApp,
|
||||
reorderApps
|
||||
} = require('../controllers/apps');
|
||||
|
||||
router
|
||||
|
@ -20,4 +21,8 @@ router
|
|||
.put(updateApp)
|
||||
.delete(deleteApp);
|
||||
|
||||
router
|
||||
.route('/0/reorder')
|
||||
.put(reorderApps);
|
||||
|
||||
module.exports = router;
|
|
@ -6,7 +6,8 @@ const {
|
|||
getCategories,
|
||||
getCategory,
|
||||
updateCategory,
|
||||
deleteCategory
|
||||
deleteCategory,
|
||||
reorderCategories
|
||||
} = require('../controllers/category');
|
||||
|
||||
router
|
||||
|
@ -20,4 +21,8 @@ router
|
|||
.put(updateCategory)
|
||||
.delete(deleteCategory);
|
||||
|
||||
router
|
||||
.route('/0/reorder')
|
||||
.put(reorderCategories);
|
||||
|
||||
module.exports = router;
|
|
@ -31,6 +31,10 @@
|
|||
{
|
||||
"key": "hideHeader",
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"key": "useOrdering",
|
||||
"value": "createdAt"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
Reference in a new issue