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/
|
node_modules/
|
||||||
data/
|
data/
|
||||||
.env
|
|
26
README.md
26
README.md
|
@ -8,7 +8,7 @@
|
||||||
![Homescreen screenshot](./github/_home.png)
|
![Homescreen screenshot](./github/_home.png)
|
||||||
|
|
||||||
## Description
|
## 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
|
## Technology
|
||||||
- Backend
|
- Backend
|
||||||
|
@ -23,6 +23,7 @@ Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI]
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
```sh
|
```sh
|
||||||
|
# clone repository
|
||||||
git clone https://github.com/pawelmalak/flame
|
git clone https://github.com/pawelmalak/flame
|
||||||
cd flame
|
cd flame
|
||||||
|
|
||||||
|
@ -33,13 +34,23 @@ npm run dev-init
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment with Docker
|
## Building Docker images
|
||||||
```sh
|
```sh
|
||||||
# build image
|
# build image for amd64 only
|
||||||
docker build -t flame .
|
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
|
# 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
|
## Functionality
|
||||||
|
@ -73,4 +84,9 @@ docker run -p 5005:5005 -v <host_dir>:/app/data flame
|
||||||
- Redirect: `https://{dest}`
|
- Redirect: `https://{dest}`
|
||||||
- URL without protocol
|
- URL without protocol
|
||||||
- Format: `www.domain.com`, `domain.com`, `sub.domain.com`, `local`, `ip`, `ip:port`
|
- 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"
|
"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": {
|
"@types/react-dom": {
|
||||||
"version": "17.0.3",
|
"version": "17.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
|
||||||
|
@ -4614,6 +4622,14 @@
|
||||||
"postcss": "^7.0.5"
|
"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": {
|
"css-color-names": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
|
"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": {
|
"memory-fs": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
||||||
|
@ -12300,6 +12321,11 @@
|
||||||
"performance-now": "^2.1.0"
|
"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": {
|
"randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
|
@ -12362,6 +12388,20 @@
|
||||||
"whatwg-fetch": "^3.4.1"
|
"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": {
|
"react-dev-utils": {
|
||||||
"version": "11.0.4",
|
"version": "11.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
||||||
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
|
"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": {
|
"util": {
|
||||||
"version": "0.11.1",
|
"version": "0.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
|
||||||
|
|
|
@ -11,12 +11,14 @@
|
||||||
"@types/jest": "^26.0.23",
|
"@types/jest": "^26.0.23",
|
||||||
"@types/node": "^12.20.12",
|
"@types/node": "^12.20.12",
|
||||||
"@types/react": "^17.0.5",
|
"@types/react": "^17.0.5",
|
||||||
|
"@types/react-beautiful-dnd": "^13.0.0",
|
||||||
"@types/react-dom": "^17.0.3",
|
"@types/react-dom": "^17.0.3",
|
||||||
"@types/react-redux": "^7.1.16",
|
"@types/react-redux": "^7.1.16",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
"http-proxy-middleware": "^2.0.0",
|
"http-proxy-middleware": "^2.0.0",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-redux": "^7.2.4",
|
"react-redux": "^7.2.4",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
|
|
|
@ -5,6 +5,10 @@ import { getConfig, setTheme } from './store/actions';
|
||||||
import { store } from './store/store';
|
import { store } from './store/store';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { checkVersion } from './utility';
|
||||||
|
|
||||||
|
// Routes
|
||||||
import Home from './components/Home/Home';
|
import Home from './components/Home/Home';
|
||||||
import Apps from './components/Apps/Apps';
|
import Apps from './components/Apps/Apps';
|
||||||
import Settings from './components/Settings/Settings';
|
import Settings from './components/Settings/Settings';
|
||||||
|
@ -14,10 +18,14 @@ import NotificationCenter from './components/NotificationCenter/NotificationCent
|
||||||
// Get config pairs from database
|
// Get config pairs from database
|
||||||
store.dispatch<any>(getConfig());
|
store.dispatch<any>(getConfig());
|
||||||
|
|
||||||
|
// Set theme
|
||||||
if (localStorage.theme) {
|
if (localStorage.theme) {
|
||||||
store.dispatch<any>(setTheme(localStorage.theme));
|
store.dispatch<any>(setTheme(localStorage.theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
checkVersion();
|
||||||
|
|
||||||
const App = (): JSX.Element => {
|
const App = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
|
|
@ -9,4 +9,21 @@
|
||||||
|
|
||||||
.TableAction:hover {
|
.TableAction:hover {
|
||||||
cursor: pointer;
|
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 { Fragment, KeyboardEvent, useState, useEffect } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||||
import { App, GlobalState } from '../../../interfaces';
|
import { Link } from 'react-router-dom';
|
||||||
import { pinApp, deleteApp } from '../../../store/actions';
|
|
||||||
|
|
||||||
|
// 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';
|
import classes from './AppTable.module.css';
|
||||||
|
|
||||||
|
// UI
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
import Table from '../../UI/Table/Table';
|
import Table from '../../UI/Table/Table';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { searchConfig } from '../../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
apps: App[];
|
apps: App[];
|
||||||
pinApp: (app: App) => void;
|
pinApp: (app: App) => void;
|
||||||
deleteApp: (id: number) => void;
|
deleteApp: (id: number) => void;
|
||||||
updateAppHandler: (app: App) => void;
|
updateAppHandler: (app: App) => void;
|
||||||
|
reorderApps: (apps: App[]) => void;
|
||||||
|
updateConfig: (formData: any) => void;
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppTable = (props: ComponentProps): JSX.Element => {
|
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 deleteAppHandler = (app: App): void => {
|
||||||
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
|
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
|
||||||
|
|
||||||
|
@ -23,55 +55,111 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Support keyboard navigation for actions
|
||||||
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
|
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handler(app);
|
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 (
|
return (
|
||||||
<Table headers={[
|
<Fragment>
|
||||||
'Name',
|
<div className={classes.Message}>
|
||||||
'URL',
|
{isCustomOrder
|
||||||
'Icon',
|
? <p>You can drag and drop single rows to reorder application</p>
|
||||||
'Actions'
|
: <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
|
||||||
]}>
|
}
|
||||||
{props.apps.map((app: App): JSX.Element => {
|
</div>
|
||||||
return (
|
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||||
<tr key={app.id}>
|
<Droppable droppableId='apps'>
|
||||||
<td>{app.name}</td>
|
{(provided) => (
|
||||||
<td>{app.url}</td>
|
<Table headers={[
|
||||||
<td>{app.icon}</td>
|
'Name',
|
||||||
<td className={classes.TableActions}>
|
'URL',
|
||||||
<div
|
'Icon',
|
||||||
className={classes.TableAction}
|
'Actions'
|
||||||
onClick={() => deleteAppHandler(app)}
|
]}
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
innerRef={provided.innerRef}>
|
||||||
tabIndex={0}>
|
{localApps.map((app: App, index): JSX.Element => {
|
||||||
<Icon icon='mdiDelete' />
|
return (
|
||||||
</div>
|
<Draggable key={app.id} draggableId={app.id.toString()} index={index}>
|
||||||
<div
|
{(provided, snapshot) => {
|
||||||
className={classes.TableAction}
|
const style = {
|
||||||
onClick={() => props.updateAppHandler(app)}
|
border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
borderRadius: '4px',
|
||||||
tabIndex={0}>
|
...provided.draggableProps.style,
|
||||||
<Icon icon='mdiPencil' />
|
};
|
||||||
</div>
|
|
||||||
<div
|
return (
|
||||||
className={classes.TableAction}
|
<tr
|
||||||
onClick={() => props.pinApp(app)}
|
{...provided.draggableProps}
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
|
{...provided.dragHandleProps}
|
||||||
tabIndex={0}>
|
ref={provided.innerRef}
|
||||||
{app.isPinned
|
style={style}
|
||||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
>
|
||||||
: <Icon icon='mdiPin' />
|
<td style={{ width:'200px' }}>{app.name}</td>
|
||||||
}
|
<td style={{ width:'200px' }}>{app.url}</td>
|
||||||
</div>
|
<td style={{ width:'200px' }}>{app.icon}</td>
|
||||||
</td>
|
{!snapshot.isDragging && (
|
||||||
</tr>
|
<td className={classes.TableActions}>
|
||||||
)
|
<div
|
||||||
})}
|
className={classes.TableAction}
|
||||||
</Table>
|
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',
|
url: 'string',
|
||||||
icon: 'string',
|
icon: 'string',
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
orderId: 0,
|
||||||
id: 0,
|
id: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
|
|
|
@ -9,4 +9,21 @@
|
||||||
|
|
||||||
.TableAction:hover {
|
.TableAction:hover {
|
||||||
cursor: pointer;
|
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 { KeyboardEvent, useState, useEffect, Fragment } from 'react';
|
||||||
import classes from './BookmarkTable.module.css';
|
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||||
import { connect } from 'react-redux';
|
import { Link } from 'react-router-dom';
|
||||||
import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions';
|
|
||||||
import { KeyboardEvent } from 'react';
|
|
||||||
|
|
||||||
|
// 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 Table from '../../UI/Table/Table';
|
||||||
import { Bookmark, Category } from '../../../interfaces';
|
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { searchConfig } from '../../../utility';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
contentType: ContentType;
|
contentType: ContentType;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
@ -15,9 +27,28 @@ interface ComponentProps {
|
||||||
deleteCategory: (id: number) => void;
|
deleteCategory: (id: number) => void;
|
||||||
updateHandler: (data: Category | Bookmark) => void;
|
updateHandler: (data: Category | Bookmark) => void;
|
||||||
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
reorderCategories: (categories: Category[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
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 deleteCategoryHandler = (category: Category): void => {
|
||||||
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
|
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
|
||||||
|
|
||||||
|
@ -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) {
|
if (props.contentType === ContentType.category) {
|
||||||
return (
|
return (
|
||||||
<Table headers={[
|
<Fragment>
|
||||||
'Name',
|
<div className={classes.Message}>
|
||||||
'Actions'
|
{isCustomOrder
|
||||||
]}>
|
? <p>You can drag and drop single rows to reorder categories</p>
|
||||||
{props.categories.map((category: Category) => {
|
: <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
|
||||||
return (
|
}
|
||||||
<tr key={category.id}>
|
</div>
|
||||||
<td>{category.name}</td>
|
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||||
<td className={classes.TableActions}>
|
<Droppable droppableId='categories'>
|
||||||
<div
|
{(provided) => (
|
||||||
className={classes.TableAction}
|
<Table headers={[
|
||||||
onClick={() => deleteCategoryHandler(category)}
|
'Name',
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)}
|
'Actions'
|
||||||
tabIndex={0}>
|
]}
|
||||||
<Icon icon='mdiDelete' />
|
innerRef={provided.innerRef}>
|
||||||
</div>
|
{localCategories.map((category: Category, index): JSX.Element => {
|
||||||
<div
|
return (
|
||||||
className={classes.TableAction}
|
<Draggable key={category.id} draggableId={category.id.toString()} index={index}>
|
||||||
onClick={() => props.updateHandler(category)}
|
{(provided, snapshot) => {
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
const style = {
|
||||||
tabIndex={0}>
|
border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
|
||||||
<Icon icon='mdiPencil' />
|
borderRadius: '4px',
|
||||||
</div>
|
...provided.draggableProps.style,
|
||||||
<div
|
};
|
||||||
className={classes.TableAction}
|
|
||||||
onClick={() => props.pinCategory(category)}
|
return (
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)}
|
<tr
|
||||||
tabIndex={0}>
|
{...provided.draggableProps}
|
||||||
{category.isPinned
|
{...provided.dragHandleProps}
|
||||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
ref={provided.innerRef}
|
||||||
: <Icon icon='mdiPin' />
|
style={style}
|
||||||
}
|
>
|
||||||
</div>
|
<td>{category.name}</td>
|
||||||
</td>
|
{!snapshot.isDragging && (
|
||||||
</tr>
|
<td className={classes.TableActions}>
|
||||||
)
|
<div
|
||||||
})}
|
className={classes.TableAction}
|
||||||
</Table>
|
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 {
|
} else {
|
||||||
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
|
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
|
||||||
|
@ -111,14 +196,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
||||||
<div
|
<div
|
||||||
className={classes.TableAction}
|
className={classes.TableAction}
|
||||||
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
|
||||||
tabIndex={0}>
|
tabIndex={0}>
|
||||||
<Icon icon='mdiDelete' />
|
<Icon icon='mdiDelete' />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={classes.TableAction}
|
className={classes.TableAction}
|
||||||
onClick={() => props.updateHandler(bookmark.bookmark)}
|
onClick={() => props.updateHandler(bookmark.bookmark)}
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
|
||||||
tabIndex={0}>
|
tabIndex={0}>
|
||||||
<Icon icon='mdiPencil' />
|
<Icon icon='mdiPencil' />
|
||||||
</div>
|
</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: '',
|
name: '',
|
||||||
id: -1,
|
id: -1,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
orderId: 0,
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: 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
|
// Redux
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createNotification, updateConfig } from '../../../store/actions';
|
import { createNotification, updateConfig, sortApps, sortCategories } from '../../../store/actions';
|
||||||
|
|
||||||
// Typescript
|
// Typescript
|
||||||
import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces';
|
import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces';
|
||||||
|
@ -17,6 +17,8 @@ import { searchConfig } from '../../../utility';
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
createNotification: (notification: NewNotification) => void;
|
createNotification: (notification: NewNotification) => void;
|
||||||
updateConfig: (formData: SettingsForm) => void;
|
updateConfig: (formData: SettingsForm) => void;
|
||||||
|
sortApps: () => void;
|
||||||
|
sortCategories: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +28,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
customTitle: document.title,
|
customTitle: document.title,
|
||||||
pinAppsByDefault: 1,
|
pinAppsByDefault: 1,
|
||||||
pinCategoriesByDefault: 1,
|
pinCategoriesByDefault: 1,
|
||||||
hideHeader: 0
|
hideHeader: 0,
|
||||||
|
useOrdering: 'createdAt'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get config
|
// Get config
|
||||||
|
@ -35,7 +38,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
customTitle: searchConfig('customTitle', 'Flame'),
|
customTitle: searchConfig('customTitle', 'Flame'),
|
||||||
pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
|
pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
|
||||||
pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
|
pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
|
||||||
hideHeader: searchConfig('hideHeader', 0)
|
hideHeader: searchConfig('hideHeader', 0),
|
||||||
|
useOrdering: searchConfig('useOrdering', 'createdAt')
|
||||||
})
|
})
|
||||||
}, [props.loading]);
|
}, [props.loading]);
|
||||||
|
|
||||||
|
@ -46,8 +50,12 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
// Save settings
|
// Save settings
|
||||||
await props.updateConfig(formData);
|
await props.updateConfig(formData);
|
||||||
|
|
||||||
// update local page title
|
// Update local page title
|
||||||
document.title = formData.customTitle;
|
document.title = formData.customTitle;
|
||||||
|
|
||||||
|
// Sort apps and categories with new settings
|
||||||
|
props.sortApps();
|
||||||
|
props.sortCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input handler
|
// Input handler
|
||||||
|
@ -113,6 +121,19 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
<option value={0}>False</option>
|
<option value={0}>False</option>
|
||||||
</select>
|
</select>
|
||||||
</InputGroup>
|
</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>
|
<Button>Save changes</Button>
|
||||||
</form>
|
</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 classes from './Settings.module.css';
|
||||||
|
|
||||||
import { Container } from '../UI/Layout/Layout';
|
import { Container } from '../UI/Layout/Layout';
|
||||||
import Headline from '../UI/Headlines/Headline/Headline';
|
import Headline from '../UI/Headlines/Headline/Headline';
|
||||||
|
|
||||||
import Themer from '../Themer/Themer';
|
import Themer from '../Themer/Themer';
|
||||||
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
||||||
import OtherSettings from './OtherSettings/OtherSettings';
|
import OtherSettings from './OtherSettings/OtherSettings';
|
||||||
|
import AppDetails from './AppDetails/AppDetails';
|
||||||
|
|
||||||
const Settings = (): JSX.Element => {
|
const Settings = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
|
@ -38,12 +40,20 @@ const Settings = (): JSX.Element => {
|
||||||
to='/settings/other'>
|
to='/settings/other'>
|
||||||
Other
|
Other
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
className={classes.SettingsNavLink}
|
||||||
|
activeClassName={classes.SettingsNavLinkActive}
|
||||||
|
exact
|
||||||
|
to='/settings/app'>
|
||||||
|
App
|
||||||
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
<section className={classes.SettingsContent}>
|
<section className={classes.SettingsContent}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/settings' component={Themer} />
|
<Route exact path='/settings' component={Themer} />
|
||||||
<Route path='/settings/weather' component={WeatherSettings} />
|
<Route path='/settings/weather' component={WeatherSettings} />
|
||||||
<Route path='/settings/other' component={OtherSettings} />
|
<Route path='/settings/other' component={OtherSettings} />
|
||||||
|
<Route path='/settings/app' component={AppDetails} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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'
|
placeholder='52.22'
|
||||||
value={formData.lat}
|
value={formData.lat}
|
||||||
onChange={(e) => inputChangeHandler(e, true)}
|
onChange={(e) => inputChangeHandler(e, true)}
|
||||||
|
step='any'
|
||||||
|
lang='en-150'
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
You can use
|
You can use
|
||||||
|
@ -135,6 +137,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
placeholder='21.01'
|
placeholder='21.01'
|
||||||
value={formData.long}
|
value={formData.long}
|
||||||
onChange={(e) => inputChangeHandler(e, true)}
|
onChange={(e) => inputChangeHandler(e, true)}
|
||||||
|
step='any'
|
||||||
|
lang='en-150'
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
|
|
|
@ -6,8 +6,7 @@
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Button:hover,
|
.Button:hover {
|
||||||
.Button:focus {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-background);
|
color: var(--color-background);
|
||||||
|
|
|
@ -2,10 +2,20 @@ import classes from './Button.module.css';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
children: string;
|
children: string;
|
||||||
|
click?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = (props: ComponentProps): JSX.Element => {
|
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;
|
export default Button;
|
|
@ -8,15 +8,17 @@
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Table th,
|
.Table th,
|
||||||
.Table td {
|
.Table td {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Head */
|
/* Head */
|
||||||
|
|
||||||
.Table th {
|
.Table th {
|
||||||
--header-radius: 4px;
|
--header-radius: 4px;
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
|
@ -34,8 +36,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Body */
|
/* Body */
|
||||||
|
|
||||||
.Table td {
|
.Table td {
|
||||||
/* opacity: 0.5; */
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
|
@ -3,11 +3,12 @@ import classes from './Table.module.css';
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
innerRef?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = (props: ComponentProps): JSX.Element => {
|
const Table = (props: ComponentProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className={classes.TableContainer}>
|
<div className={classes.TableContainer} ref={props.innerRef}>
|
||||||
<table className={classes.Table}>
|
<table className={classes.Table}>
|
||||||
<thead className={classes.TableHead}>
|
<thead className={classes.TableHead}>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -5,6 +5,7 @@ export interface App extends Model {
|
||||||
url: string;
|
url: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
orderId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewApp {
|
export interface NewApp {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Model, Bookmark } from '.';
|
||||||
export interface Category extends Model {
|
export interface Category extends Model {
|
||||||
name: string;
|
name: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
orderId: number;
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,4 +10,5 @@ export interface SettingsForm {
|
||||||
pinAppsByDefault: number;
|
pinAppsByDefault: number;
|
||||||
pinCategoriesByDefault: number;
|
pinCategoriesByDefault: number;
|
||||||
hideHeader: number;
|
hideHeader: number;
|
||||||
|
useOrdering: string;
|
||||||
}
|
}
|
|
@ -7,12 +7,16 @@ import {
|
||||||
AddAppAction,
|
AddAppAction,
|
||||||
DeleteAppAction,
|
DeleteAppAction,
|
||||||
UpdateAppAction,
|
UpdateAppAction,
|
||||||
|
ReorderAppsAction,
|
||||||
|
SortAppsAction,
|
||||||
// Categories
|
// Categories
|
||||||
GetCategoriesAction,
|
GetCategoriesAction,
|
||||||
AddCategoryAction,
|
AddCategoryAction,
|
||||||
PinCategoryAction,
|
PinCategoryAction,
|
||||||
DeleteCategoryAction,
|
DeleteCategoryAction,
|
||||||
UpdateCategoryAction,
|
UpdateCategoryAction,
|
||||||
|
SortCategoriesAction,
|
||||||
|
ReorderCategoriesAction,
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
AddBookmarkAction,
|
AddBookmarkAction,
|
||||||
DeleteBookmarkAction,
|
DeleteBookmarkAction,
|
||||||
|
@ -37,6 +41,8 @@ export enum ActionTypes {
|
||||||
addAppSuccess = 'ADD_APP_SUCCESS',
|
addAppSuccess = 'ADD_APP_SUCCESS',
|
||||||
deleteApp = 'DELETE_APP',
|
deleteApp = 'DELETE_APP',
|
||||||
updateApp = 'UPDATE_APP',
|
updateApp = 'UPDATE_APP',
|
||||||
|
reorderApps = 'REORDER_APPS',
|
||||||
|
sortApps = 'SORT_APPS',
|
||||||
// Categories
|
// Categories
|
||||||
getCategories = 'GET_CATEGORIES',
|
getCategories = 'GET_CATEGORIES',
|
||||||
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
|
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
|
||||||
|
@ -45,6 +51,8 @@ export enum ActionTypes {
|
||||||
pinCategory = 'PIN_CATEGORY',
|
pinCategory = 'PIN_CATEGORY',
|
||||||
deleteCategory = 'DELETE_CATEGORY',
|
deleteCategory = 'DELETE_CATEGORY',
|
||||||
updateCategory = 'UPDATE_CATEGORY',
|
updateCategory = 'UPDATE_CATEGORY',
|
||||||
|
sortCategories = 'SORT_CATEGORIES',
|
||||||
|
reorderCategories = 'REORDER_CATEGORIES',
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
addBookmark = 'ADD_BOOKMARK',
|
addBookmark = 'ADD_BOOKMARK',
|
||||||
deleteBookmark = 'DELETE_BOOKMARK',
|
deleteBookmark = 'DELETE_BOOKMARK',
|
||||||
|
@ -66,12 +74,16 @@ export type Action =
|
||||||
AddAppAction |
|
AddAppAction |
|
||||||
DeleteAppAction |
|
DeleteAppAction |
|
||||||
UpdateAppAction |
|
UpdateAppAction |
|
||||||
|
ReorderAppsAction |
|
||||||
|
SortAppsAction |
|
||||||
// Categories
|
// Categories
|
||||||
GetCategoriesAction<any> |
|
GetCategoriesAction<any> |
|
||||||
AddCategoryAction |
|
AddCategoryAction |
|
||||||
PinCategoryAction |
|
PinCategoryAction |
|
||||||
DeleteCategoryAction |
|
DeleteCategoryAction |
|
||||||
UpdateCategoryAction |
|
UpdateCategoryAction |
|
||||||
|
SortCategoriesAction |
|
||||||
|
ReorderCategoriesAction |
|
||||||
// Bookmarks
|
// Bookmarks
|
||||||
AddBookmarkAction |
|
AddBookmarkAction |
|
||||||
DeleteBookmarkAction |
|
DeleteBookmarkAction |
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { ActionTypes } from './actionTypes';
|
import { ActionTypes } from './actionTypes';
|
||||||
import { App, ApiResponse, NewApp } from '../../interfaces';
|
import { App, ApiResponse, NewApp, Config } from '../../interfaces';
|
||||||
import { CreateNotificationAction } from './notification';
|
import { CreateNotificationAction } from './notification';
|
||||||
|
|
||||||
export interface GetAppsAction<T> {
|
export interface GetAppsAction<T> {
|
||||||
|
@ -73,10 +73,13 @@ export const addApp = (formData: NewApp) => async (dispatch: Dispatch) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
dispatch<AddAppAction>({
|
await dispatch<AddAppAction>({
|
||||||
type: ActionTypes.addAppSuccess,
|
type: ActionTypes.addAppSuccess,
|
||||||
payload: res.data.data
|
payload: res.data.data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sort apps
|
||||||
|
dispatch<any>(sortApps())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(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,
|
type: ActionTypes.updateApp,
|
||||||
payload: res.data.data
|
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) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { ActionTypes } from './actionTypes';
|
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';
|
import { CreateNotificationAction } from './notification';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -54,6 +54,8 @@ export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch)
|
||||||
type: ActionTypes.addCategory,
|
type: ActionTypes.addCategory,
|
||||||
payload: res.data.data
|
payload: res.data.data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dispatch<any>(sortCategories());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
@ -173,6 +175,8 @@ export const updateCategory = (id: number, formData: NewCategory) => async (disp
|
||||||
type: ActionTypes.updateCategory,
|
type: ActionTypes.updateCategory,
|
||||||
payload: res.data.data
|
payload: res.data.data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
dispatch<any>(sortCategories());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
@ -261,4 +265,60 @@ export const updateBookmark = (bookmarkId: number, formData: NewBookmark, previo
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(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 { ActionTypes, Action } from '../actions';
|
||||||
import { App } from '../../interfaces/App';
|
import { App } from '../../interfaces/App';
|
||||||
|
import { sortData } from '../../utility';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
@ -52,11 +53,9 @@ const pinApp = (state: State, action: Action): State => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const addAppSuccess = (state: State, action: Action): State => {
|
const addAppSuccess = (state: State, action: Action): State => {
|
||||||
const tmpApps = [...state.apps, action.payload];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...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) => {
|
const appReducer = (state = initialState, action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionTypes.getApps: return getApps(state, action);
|
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.addAppSuccess: return addAppSuccess(state, action);
|
||||||
case ActionTypes.deleteApp: return deleteApp(state, action);
|
case ActionTypes.deleteApp: return deleteApp(state, action);
|
||||||
case ActionTypes.updateApp: return updateApp(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;
|
default: return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ActionTypes, Action } from '../actions';
|
import { ActionTypes, Action } from '../actions';
|
||||||
import { Category, Bookmark } from '../../interfaces';
|
import { Category, Bookmark } from '../../interfaces';
|
||||||
|
import { sortData } from '../../utility';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
loading: boolean;
|
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) => {
|
const bookmarkReducer = (state = initialState, action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionTypes.getCategories: return getCategories(state, action);
|
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.updateCategory: return updateCategory(state, action);
|
||||||
case ActionTypes.deleteBookmark: return deleteBookmark(state, action);
|
case ActionTypes.deleteBookmark: return deleteBookmark(state, action);
|
||||||
case ActionTypes.updateBookmark: return updateBookmark(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;
|
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 './iconParser';
|
||||||
export * from './urlParser';
|
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 {
|
} else {
|
||||||
return pair.value;
|
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 ErrorResponse = require('../utils/ErrorResponse');
|
||||||
const App = require('../models/App');
|
const App = require('../models/App');
|
||||||
const Config = require('../models/Config');
|
const Config = require('../models/Config');
|
||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
|
||||||
// @desc Create new app
|
// @desc Create new app
|
||||||
// @route POST /api/apps
|
// @route POST /api/apps
|
||||||
|
@ -35,10 +36,24 @@ exports.createApp = asyncWrapper(async (req, res, next) => {
|
||||||
// @route GET /api/apps
|
// @route GET /api/apps
|
||||||
// @access Public
|
// @access Public
|
||||||
exports.getApps = asyncWrapper(async (req, res, next) => {
|
exports.getApps = asyncWrapper(async (req, res, next) => {
|
||||||
const apps = await App.findAll({
|
// Get config from database
|
||||||
order: [['name', 'ASC']]
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: apps
|
data: apps
|
||||||
|
@ -91,6 +106,22 @@ exports.deleteApp = asyncWrapper(async (req, res, next) => {
|
||||||
where: { id: req.params.id }
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {}
|
data: {}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const asyncWrapper = require('../middleware/asyncWrapper');
|
const asyncWrapper = require('../middleware/asyncWrapper');
|
||||||
const ErrorResponse = require('../utils/ErrorResponse');
|
const ErrorResponse = require('../utils/ErrorResponse');
|
||||||
const Bookmark = require('../models/Bookmark');
|
const Bookmark = require('../models/Bookmark');
|
||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
|
||||||
// @desc Create new bookmark
|
// @desc Create new bookmark
|
||||||
// @route POST /api/bookmarks
|
// @route POST /api/bookmarks
|
||||||
|
@ -19,7 +20,7 @@ exports.createBookmark = asyncWrapper(async (req, res, next) => {
|
||||||
// @access Public
|
// @access Public
|
||||||
exports.getBookmarks = asyncWrapper(async (req, res, next) => {
|
exports.getBookmarks = asyncWrapper(async (req, res, next) => {
|
||||||
const bookmarks = await Bookmark.findAll({
|
const bookmarks = await Bookmark.findAll({
|
||||||
order: [['name', 'ASC']]
|
order: [[ Sequelize.fn('lower', Sequelize.col('name')), 'ASC' ]]
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
|
@ -3,6 +3,7 @@ const ErrorResponse = require('../utils/ErrorResponse');
|
||||||
const Category = require('../models/Category');
|
const Category = require('../models/Category');
|
||||||
const Bookmark = require('../models/Bookmark');
|
const Bookmark = require('../models/Bookmark');
|
||||||
const Config = require('../models/Config');
|
const Config = require('../models/Config');
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
// @desc Create new category
|
// @desc Create new category
|
||||||
// @route POST /api/categories
|
// @route POST /api/categories
|
||||||
|
@ -36,14 +37,32 @@ exports.createCategory = asyncWrapper(async (req, res, next) => {
|
||||||
// @route GET /api/categories
|
// @route GET /api/categories
|
||||||
// @access Public
|
// @access Public
|
||||||
exports.getCategories = asyncWrapper(async (req, res, next) => {
|
exports.getCategories = asyncWrapper(async (req, res, next) => {
|
||||||
const categories = await Category.findAll({
|
// Get config from database
|
||||||
include: [{
|
const useOrdering = await Config.findOne({
|
||||||
model: Bookmark,
|
where: { key: 'useOrdering' }
|
||||||
as: 'bookmarks'
|
|
||||||
}],
|
|
||||||
order: [['name', 'ASC']]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: categories
|
data: categories
|
||||||
|
@ -118,6 +137,22 @@ exports.deleteCategory = asyncWrapper(async (req, res, next) => {
|
||||||
where: { id: req.params.id }
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {}
|
data: {}
|
||||||
|
|
|
@ -18,6 +18,11 @@ const App = sequelize.define('App', {
|
||||||
isPinned: {
|
isPinned: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'apps'
|
tableName: 'apps'
|
||||||
|
|
|
@ -9,6 +9,11 @@ const Category = sequelize.define('Category', {
|
||||||
isPinned: {
|
isPinned: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'categories'
|
tableName: 'categories'
|
||||||
|
|
|
@ -6,7 +6,8 @@ const {
|
||||||
getApps,
|
getApps,
|
||||||
getApp,
|
getApp,
|
||||||
updateApp,
|
updateApp,
|
||||||
deleteApp
|
deleteApp,
|
||||||
|
reorderApps
|
||||||
} = require('../controllers/apps');
|
} = require('../controllers/apps');
|
||||||
|
|
||||||
router
|
router
|
||||||
|
@ -20,4 +21,8 @@ router
|
||||||
.put(updateApp)
|
.put(updateApp)
|
||||||
.delete(deleteApp);
|
.delete(deleteApp);
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/0/reorder')
|
||||||
|
.put(reorderApps);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
|
@ -6,7 +6,8 @@ const {
|
||||||
getCategories,
|
getCategories,
|
||||||
getCategory,
|
getCategory,
|
||||||
updateCategory,
|
updateCategory,
|
||||||
deleteCategory
|
deleteCategory,
|
||||||
|
reorderCategories
|
||||||
} = require('../controllers/category');
|
} = require('../controllers/category');
|
||||||
|
|
||||||
router
|
router
|
||||||
|
@ -20,4 +21,8 @@ router
|
||||||
.put(updateCategory)
|
.put(updateCategory)
|
||||||
.delete(deleteCategory);
|
.delete(deleteCategory);
|
||||||
|
|
||||||
|
router
|
||||||
|
.route('/0/reorder')
|
||||||
|
.put(reorderCategories);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
|
@ -31,6 +31,10 @@
|
||||||
{
|
{
|
||||||
"key": "hideHeader",
|
"key": "hideHeader",
|
||||||
"value": false
|
"value": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "useOrdering",
|
||||||
|
"value": "createdAt"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
Loading…
Reference in a new issue