import from up to date repo
This commit is contained in:
parent
35f5db62f2
commit
12baf72567
86 changed files with 12080 additions and 13746 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -1,3 +1,19 @@
|
|||
### v2.4.0 (2022-11-27)
|
||||
First release under mhzawadi/flame.
|
||||
|
||||
- **Major change - replaced `redux` with `jotai` for client state management.**
|
||||
- Enabled experimental support for app icons from [dashboard-icons](https://github.com/walkxcode/Dashboard-Icons)
|
||||
- Added hover effects for Section Headlines
|
||||
- Replaced dropdowns with checkboxes for True/False settings
|
||||
- Tweak UI margins to look closer to SUI
|
||||
|
||||
Also incorporates:
|
||||
- Automatically clear search bar ([pawelmalak/flame#265](https://github.com/pawelmalak/flame/pull/265) by @IDevJoe)
|
||||
- Enable non-root container build ([pawelmalak/flame#309](https://github.com/pawelmalak/flame/pull/309) by @luckyf)
|
||||
- bugfix: sameTab does not work if prefix is localSearch ([pawelmalak/flame#284](https://github.com/pawelmalak/flame/pull/284) by @pmjklemm)
|
||||
- Allow the image to run as non-root ([pawelmalak/flame#356](https://github.com/pawelmalak/flame/pull/356) by @glitchcrab)
|
||||
- Enforce no border-radius on search bar ([pawelmalak/flame#395](https://github.com/pawelmalak/flame/pull/395) by @davidchalifoux)
|
||||
|
||||
### v2.3.0 (2022-03-25)
|
||||
- Added custom theme editor ([#246](https://github.com/pawelmalak/flame/issues/246))
|
||||
- Added option to set secondary search provider ([#295](https://github.com/pawelmalak/flame/issues/295))
|
||||
|
|
32
README.md
32
README.md
|
@ -2,6 +2,16 @@
|
|||
|
||||

|
||||
|
||||
## mhzawadi/flame
|
||||
|
||||
This is a hard fork of https://github.com/pawelmalak/flame.
|
||||
|
||||
I forked because I wanted to try using Flame, but it seems it's abandoned with 100 issues and 25 open PRs.
|
||||
I decided to merge the changes from some of the open PRs in my `master` and go from there 🙂.
|
||||
|
||||
Note: I was not an active Flame contributor. I have my own set of features I want to build on top of that.
|
||||
PRs are welcome.
|
||||
|
||||
## Description
|
||||
|
||||
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 application hub in no time - no file editing necessary.
|
||||
|
@ -19,23 +29,23 @@ Flame is self-hosted startpage for your server. Its design is inspired (heavily)
|
|||
|
||||
### With Docker (recommended)
|
||||
|
||||
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
|
||||
[Docker Hub link](https://hub.docker.com/r/mhzawadi/flame)
|
||||
|
||||
```sh
|
||||
docker pull pawelmalak/flame
|
||||
docker pull mhzawadi/flame
|
||||
|
||||
# for ARM architecture (e.g. RaspberryPi)
|
||||
docker pull pawelmalak/flame:multiarch
|
||||
docker pull mhzawadi/flame:multiarch
|
||||
|
||||
# installing specific version
|
||||
docker pull pawelmalak/flame:2.0.0
|
||||
docker pull mhzawadi/flame:2.0.0
|
||||
```
|
||||
|
||||
#### Deployment
|
||||
|
||||
```sh
|
||||
# run container
|
||||
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password pawelmalak/flame
|
||||
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password mhzawadi/flame
|
||||
```
|
||||
|
||||
#### Building images
|
||||
|
@ -59,7 +69,7 @@ version: '3.6'
|
|||
|
||||
services:
|
||||
flame:
|
||||
image: pawelmalak/flame
|
||||
image: mhzawadi/flame
|
||||
container_name: flame
|
||||
volumes:
|
||||
- /path/to/host/data:/app/data
|
||||
|
@ -123,7 +133,7 @@ Follow instructions from wiki: [Installation without Docker](https://github.com/
|
|||
|
||||
```sh
|
||||
# clone repository
|
||||
git clone https://github.com/pawelmalak/flame
|
||||
git clone https://github.com/mhzawadi/flame
|
||||
cd flame
|
||||
|
||||
# run only once
|
||||
|
@ -219,10 +229,10 @@ In order to use the Kubernetes integration, each ingress must have the following
|
|||
```yml
|
||||
metadata:
|
||||
annotations:
|
||||
- flame.pawelmalak/type=application # "app" works too
|
||||
- flame.pawelmalak/name=My container
|
||||
- flame.pawelmalak/url=https://example.com
|
||||
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
|
||||
- flame.georgesg/type=application # "app" works too
|
||||
- flame.georgesg/name=My container
|
||||
- flame.georgesg/url=https://example.com
|
||||
- flame.georgesg/icon=icon-name # optional, default is "kubernetes"
|
||||
```
|
||||
|
||||
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker
|
||||
|
|
|
@ -1 +1 @@
|
|||
REACT_APP_VERSION=2.3.0
|
||||
REACT_APP_VERSION=2.4.0
|
||||
|
|
21800
client/package-lock.json
generated
21800
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,10 +1,25 @@
|
|||
{
|
||||
"name": "client",
|
||||
"version": "0.1.0",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@mdi/js": "^6.4.95",
|
||||
"@mdi/react": "^1.5.0",
|
||||
"axios": "^0.24.0",
|
||||
"classnames": "^2.3.2",
|
||||
"external-svg-loader": "^1.3.4",
|
||||
"http-proxy-middleware": "^2.0.1",
|
||||
"jotai": "^1.10.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"skycons-ts": "^0.2.0",
|
||||
"web-vitals": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^5.15.0",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
|
@ -13,24 +28,9 @@
|
|||
"@types/react": "^17.0.34",
|
||||
"@types/react-beautiful-dnd": "^13.1.2",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-redux": "^7.1.20",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"axios": "^0.24.0",
|
||||
"external-svg-loader": "^1.3.4",
|
||||
"http-proxy-middleware": "^2.0.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"redux": "^4.1.2",
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"redux-thunk": "^2.4.0",
|
||||
"skycons-ts": "^0.2.0",
|
||||
"typescript": "^4.4.4",
|
||||
"web-vitals": "^2.1.2"
|
||||
"prettier": "^2.4.1",
|
||||
"typescript": "^4.4.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
@ -55,8 +55,5 @@
|
|||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,38 +1,43 @@
|
|||
import 'external-svg-loader';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Route, Switch } from 'react-router-dom';
|
||||
import 'external-svg-loader';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { autoLogin, getConfig } from './store/action-creators';
|
||||
import { actionCreators, store } from './store';
|
||||
import { State } from './store/reducers';
|
||||
|
||||
// Utils
|
||||
import { checkVersion, decodeToken, parsePABToTheme } from './utility';
|
||||
|
||||
// Routes
|
||||
import { Home } from './components/Home/Home';
|
||||
import { Apps } from './components/Apps/Apps';
|
||||
import { Settings } from './components/Settings/Settings';
|
||||
import { Bookmarks } from './components/Bookmarks/Bookmarks';
|
||||
import { Home } from './components/Home/Home';
|
||||
import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
|
||||
|
||||
// Get config
|
||||
store.dispatch<any>(getConfig());
|
||||
|
||||
// Validate token
|
||||
if (localStorage.token) {
|
||||
store.dispatch<any>(autoLogin());
|
||||
}
|
||||
import { Settings } from './components/Settings/Settings';
|
||||
import { Spinner } from './components/UI';
|
||||
import { useAutoLogin, useLogout } from './state/auth';
|
||||
import { configAtom, configLoadingAtom, useFetchConfig } from './state/config';
|
||||
import { infoMessage, useCreateNotification } from './state/notification';
|
||||
import { useFetchQueries } from './state/queries';
|
||||
import { useFetchThemes, useSetTheme } from './state/theme';
|
||||
import { decodeToken, parsePABToTheme, useCheckVersion } from './utility';
|
||||
|
||||
export const App = (): JSX.Element => {
|
||||
const { config, loading } = useSelector((state: State) => state.config);
|
||||
const autoLogin = useAutoLogin();
|
||||
|
||||
const dispath = useDispatch();
|
||||
const { fetchQueries, setTheme, logout, createNotification, fetchThemes } =
|
||||
bindActionCreators(actionCreators, dispath);
|
||||
// Validate token
|
||||
if (localStorage.token) {
|
||||
autoLogin();
|
||||
}
|
||||
|
||||
const getConfig = useFetchConfig();
|
||||
const config = useAtomValue(configAtom);
|
||||
const loading = useAtomValue(configLoadingAtom);
|
||||
|
||||
useEffect(() => {
|
||||
getConfig();
|
||||
}, []);
|
||||
|
||||
const createNotification = useCreateNotification();
|
||||
const setTheme = useSetTheme();
|
||||
const fetchThemes = useFetchThemes();
|
||||
const fetchQueries = useFetchQueries();
|
||||
const checkVersion = useCheckVersion();
|
||||
|
||||
const logout = useLogout();
|
||||
|
||||
useEffect(() => {
|
||||
// check if token is valid
|
||||
|
@ -43,10 +48,9 @@ export const App = (): JSX.Element => {
|
|||
|
||||
if (now > expiresIn) {
|
||||
logout();
|
||||
createNotification({
|
||||
title: 'Info',
|
||||
message: 'Session expired. You have been logged out',
|
||||
});
|
||||
createNotification(
|
||||
infoMessage('Session expired. You have been logged out')
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
@ -75,6 +79,10 @@ export const App = (): JSX.Element => {
|
|||
}
|
||||
}, [loading]);
|
||||
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BrowserRouter>
|
||||
|
|
|
@ -2,7 +2,15 @@
|
|||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.AppCard:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.AppCardIcon {
|
||||
|
@ -11,15 +19,12 @@
|
|||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.AppCardDetails {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.AppCardDetails h5 {
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: -4px;
|
||||
margin-bottom: -2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.AppCardDetails span {
|
||||
|
@ -31,13 +36,10 @@
|
|||
|
||||
@media (min-width: 500px) {
|
||||
.AppCard {
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.AppCard:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
import classes from './AppCard.module.css';
|
||||
import { Icon } from '../../UI';
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { App } from '../../../interfaces';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { configAtom } from '../../../state/config';
|
||||
import { isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
import { Icon } from '../../UI';
|
||||
import classes from './AppCard.module.css';
|
||||
|
||||
interface Props {
|
||||
app: App;
|
||||
}
|
||||
|
||||
export const AppCard = ({ app }: Props): JSX.Element => {
|
||||
const { config } = useSelector((state: State) => state.config);
|
||||
const config = useAtomValue(configAtom);
|
||||
|
||||
const [displayUrl, redirectUrl] = urlParser(app.url);
|
||||
|
||||
|
@ -41,7 +40,7 @@ export const AppCard = ({ app }: Props): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
} else {
|
||||
iconEl = <Icon icon={iconParser(icon)} />;
|
||||
iconEl = <Icon icon={icon} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,25 +1,22 @@
|
|||
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAtom } from 'jotai';
|
||||
import { ChangeEvent, SyntheticEvent, useEffect, useState } from 'react';
|
||||
import { NewApp } from '../../../interfaces';
|
||||
|
||||
import classes from './AppForm.module.css';
|
||||
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
import { appInUpdateAtom, useAddApp, useUpdateApp } from '../../../state/app';
|
||||
import { useCreateNotification } from '../../../state/notification';
|
||||
import { inputHandler, newAppTemplate } from '../../../utility';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { Button, InputGroup, ModalForm } from '../../UI';
|
||||
import classes from './AppForm.module.css';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
}
|
||||
|
||||
export const AppForm = ({ modalHandler }: Props): JSX.Element => {
|
||||
const { appInUpdate } = useSelector((state: State) => state.apps);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { addApp, updateApp, setEditApp, createNotification } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
const [appInUpdate, setEditApp] = useAtom(appInUpdateAtom);
|
||||
const addApp = useAddApp();
|
||||
const updateApp = useUpdateApp();
|
||||
|
||||
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
|
@ -164,11 +161,19 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
<span>
|
||||
Use icon name from MDI or pass a valid URL.
|
||||
Use icon name from{' '}
|
||||
<a href="https://materialdesignicons.com/" target="blank">
|
||||
{' '}
|
||||
Click here for reference
|
||||
MDI
|
||||
</a>
|
||||
, icon name from{' '}
|
||||
<a
|
||||
href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
dashboard-icons
|
||||
</a>
|
||||
, or pass a valid URL.
|
||||
</span>
|
||||
<span
|
||||
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||
|
@ -196,7 +201,7 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
|
|||
}}
|
||||
className={classes.Switch}
|
||||
>
|
||||
Switch to MDI
|
||||
Switch to icon name
|
||||
</span>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
|
|
@ -1,38 +1,41 @@
|
|||
import { Fragment, useState, useEffect } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
Droppable,
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { App } from '../../../interfaces';
|
||||
|
||||
// Other
|
||||
import { Message, Table } from '../../UI';
|
||||
import {
|
||||
appsAtom,
|
||||
useDeleteApp,
|
||||
usePinApp,
|
||||
useReorderApps,
|
||||
useUpdateApp,
|
||||
} from '../../../state/app';
|
||||
import { configAtom } from '../../../state/config';
|
||||
import { useCreateNotification } from '../../../state/notification';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import { Message, Table } from '../../UI';
|
||||
|
||||
interface Props {
|
||||
openFormForUpdating: (app: App) => void;
|
||||
}
|
||||
|
||||
export const AppTable = (props: Props): JSX.Element => {
|
||||
const {
|
||||
apps: { apps },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
const config = useAtomValue(configAtom);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { pinApp, deleteApp, reorderApps, createNotification, updateApp } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
const apps = useAtomValue(appsAtom);
|
||||
const pinApp = usePinApp();
|
||||
const deleteApp = useDeleteApp();
|
||||
const reorderApps = useReorderApps();
|
||||
const updateApp = useUpdateApp();
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||
|
||||
|
@ -87,7 +90,7 @@ export const AppTable = (props: Props): JSX.Element => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Message isPrimary={false}>
|
||||
{config.useOrdering === 'orderId' ? (
|
||||
<p>You can drag and drop single rows to reorder application</p>
|
||||
|
@ -155,6 +158,6 @@ export const AppTable = (props: Props): JSX.Element => {
|
|||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,47 +1,36 @@
|
|||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { App } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './Apps.module.css';
|
||||
|
||||
// UI
|
||||
import { Headline, Spinner, ActionButton, Modal, Container } from '../UI';
|
||||
|
||||
// Subcomponents
|
||||
import { AppGrid } from './AppGrid/AppGrid';
|
||||
import {
|
||||
appInUpdateAtom,
|
||||
appsAtom,
|
||||
appsLoadingAtom,
|
||||
useFetchApps,
|
||||
} from '../../state/app';
|
||||
import { authAtom } from '../../state/auth';
|
||||
import { ActionButton, Container, Headline, Modal, Spinner } from '../UI';
|
||||
import { AppForm } from './AppForm/AppForm';
|
||||
import { AppGrid } from './AppGrid/AppGrid';
|
||||
import classes from './Apps.module.css';
|
||||
import { AppTable } from './AppTable/AppTable';
|
||||
|
||||
// Utils
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
interface Props {
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
export const Apps = (props: Props): JSX.Element => {
|
||||
// Get Redux state
|
||||
const {
|
||||
apps: { apps, loading },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
// Get Redux action creators
|
||||
const dispatch = useDispatch();
|
||||
const { getApps, setEditApp } = bindActionCreators(actionCreators, dispatch);
|
||||
const apps = useAtomValue(appsAtom);
|
||||
const setEditApp = useSetAtom(appInUpdateAtom);
|
||||
const loading = useAtomValue(appsLoadingAtom);
|
||||
const fetchApps = useFetchApps();
|
||||
|
||||
// Load apps if array is empty
|
||||
useEffect(() => {
|
||||
if (!apps.length) {
|
||||
getApps();
|
||||
fetchApps();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -1,18 +1,12 @@
|
|||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
|
||||
// Other
|
||||
import classes from './BookmarkCard.module.css';
|
||||
import { authAtom } from '../../../state/auth';
|
||||
import { categoryInEditAtom } from '../../../state/bookmark';
|
||||
import { configAtom } from '../../../state/config';
|
||||
import { isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
import { Icon } from '../../UI';
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
import classes from './BookmarkCard.module.css';
|
||||
|
||||
interface Props {
|
||||
category: Category;
|
||||
|
@ -22,13 +16,10 @@ interface Props {
|
|||
export const BookmarkCard = (props: Props): JSX.Element => {
|
||||
const { category, fromHomepage = false } = props;
|
||||
|
||||
const {
|
||||
config: { config },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
const config = useAtomValue(configAtom);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { setEditCategory } = bindActionCreators(actionCreators, dispatch);
|
||||
const setCategoryInEdit = useSetAtom(categoryInEditAtom);
|
||||
|
||||
return (
|
||||
<div className={classes.BookmarkCard}>
|
||||
|
@ -38,7 +29,7 @@ export const BookmarkCard = (props: Props): JSX.Element => {
|
|||
}
|
||||
onClick={() => {
|
||||
if (!fromHomepage && isAuthenticated) {
|
||||
setEditCategory(category);
|
||||
setCategoryInEdit(category);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -81,7 +72,7 @@ export const BookmarkCard = (props: Props): JSX.Element => {
|
|||
} else {
|
||||
iconEl = (
|
||||
<div className={classes.BookmarkIcon}>
|
||||
<Icon icon={iconParser(icon)} />
|
||||
<Icon icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,29 +1,26 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
// Typescript
|
||||
import { Category, Bookmark } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './Bookmarks.module.css';
|
||||
|
||||
// UI
|
||||
import { Bookmark, Category } from '../../interfaces';
|
||||
import {
|
||||
ActionButton,
|
||||
Container,
|
||||
Headline,
|
||||
ActionButton,
|
||||
Spinner,
|
||||
Modal,
|
||||
Message,
|
||||
Modal,
|
||||
Spinner,
|
||||
} from '../UI';
|
||||
import classes from './Bookmarks.module.css';
|
||||
|
||||
// Components
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { authAtom } from '../../state/auth';
|
||||
import {
|
||||
bookmarkInEditAtom,
|
||||
bookmarksLoadingAtom,
|
||||
categoriesAtom,
|
||||
categoryInEditAtom,
|
||||
useFetchCategories,
|
||||
} from '../../state/bookmark';
|
||||
import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
|
||||
import { Form } from './Form/Form';
|
||||
import { Table } from './Table/Table';
|
||||
|
@ -38,21 +35,19 @@ export enum ContentType {
|
|||
}
|
||||
|
||||
export const Bookmarks = (props: Props): JSX.Element => {
|
||||
// Get Redux state
|
||||
const {
|
||||
bookmarks: { loading, categories, categoryInEdit },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
// Get Redux action creators
|
||||
const dispatch = useDispatch();
|
||||
const { getCategories, setEditCategory, setEditBookmark } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
const loading = useAtomValue(bookmarksLoadingAtom);
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const [categoryInEdit, setCategoryInEdit] = useAtom(categoryInEditAtom);
|
||||
const setBookmarkInEdit = useSetAtom(bookmarkInEditAtom);
|
||||
|
||||
const fetchCategories = useFetchCategories();
|
||||
|
||||
// Load categories if array is empty
|
||||
useEffect(() => {
|
||||
if (!categories.length) {
|
||||
getCategories();
|
||||
fetchCategories();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
@ -84,7 +79,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
|
|||
|
||||
useEffect(() => {
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
setCategoryInEdit(null);
|
||||
}, []);
|
||||
|
||||
// Form actions
|
||||
|
@ -107,10 +102,10 @@ export const Bookmarks = (props: Props): JSX.Element => {
|
|||
|
||||
if (instanceOfCategory(data)) {
|
||||
setFormContentType(ContentType.category);
|
||||
setEditCategory(data);
|
||||
setCategoryInEdit(data);
|
||||
} else {
|
||||
setFormContentType(ContentType.bookmark);
|
||||
setEditBookmark(data);
|
||||
setBookmarkInEdit(data);
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
|
@ -120,7 +115,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
|
|||
const showTableForEditing = (contentType: ContentType) => {
|
||||
// We're in the edit mode and the same button was clicked - go back to list
|
||||
if (showTable && contentType === tableContentType) {
|
||||
setEditCategory(null);
|
||||
setBookmarkInEdit(null);
|
||||
setShowTable(false);
|
||||
} else {
|
||||
setShowTable(true);
|
||||
|
@ -130,7 +125,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
|
|||
|
||||
const finishEditing = () => {
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
setBookmarkInEdit(null);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -176,9 +171,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
|
|||
<Message isPrimary={false}>
|
||||
Click on category name to edit its bookmarks
|
||||
</Message>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
|
|
|
@ -1,22 +1,19 @@
|
|||
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Bookmark, Category, NewBookmark } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
|
||||
// CSS
|
||||
import classes from './Form.module.css';
|
||||
|
||||
// Utils
|
||||
import {
|
||||
categoriesAtom,
|
||||
useAddBookmark,
|
||||
useUpdateBookmark,
|
||||
} from '../../../state/bookmark';
|
||||
import { useCreateNotification } from '../../../state/notification';
|
||||
import { inputHandler, newBookmarkTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, ModalForm } from '../../UI';
|
||||
import classes from './Form.module.css';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
|
@ -27,11 +24,11 @@ export const BookmarksForm = ({
|
|||
bookmark,
|
||||
modalHandler,
|
||||
}: Props): JSX.Element => {
|
||||
const { categories } = useSelector((state: State) => state.bookmarks);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { addBookmark, updateBookmark, createNotification } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const addBookmark = useAddBookmark();
|
||||
const updateBookmark = useUpdateBookmark();
|
||||
|
||||
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
|
@ -219,11 +216,19 @@ export const BookmarksForm = ({
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
<span>
|
||||
Use icon name from MDI or pass a valid URL.
|
||||
Use icon name from{' '}
|
||||
<a href="https://materialdesignicons.com/" target="blank">
|
||||
{' '}
|
||||
Click here for reference
|
||||
MDI
|
||||
</a>
|
||||
, icon name from{' '}
|
||||
<a
|
||||
href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
dashboard-icons
|
||||
</a>
|
||||
, or pass a valid URL.
|
||||
</span>
|
||||
<span
|
||||
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||
|
@ -250,7 +255,7 @@ export const BookmarksForm = ({
|
|||
}}
|
||||
className={classes.Switch}
|
||||
>
|
||||
Switch to MDI
|
||||
Switch to icon name
|
||||
</span>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
|
|
@ -1,18 +1,8 @@
|
|||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Category, NewCategory } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { useAddCategory, useUpdateCategory } from '../../../state/bookmark';
|
||||
import { inputHandler, newCategoryTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, ModalForm } from '../../UI';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
|
@ -23,11 +13,8 @@ export const CategoryForm = ({
|
|||
category,
|
||||
modalHandler,
|
||||
}: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { addCategory, updateCategory } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
const addCategory = useAddCategory();
|
||||
const updateCategory = useUpdateCategory();
|
||||
|
||||
const [formData, setFormData] = useState<NewCategory>(newCategoryTemplate);
|
||||
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
// Typescript
|
||||
import { ContentType } from '../Bookmarks';
|
||||
|
||||
// Utils
|
||||
import { CategoryForm } from './CategoryForm';
|
||||
import { BookmarksForm } from './BookmarksForm';
|
||||
import { Fragment } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
bookmarkInEditAtom,
|
||||
categoryInEditAtom,
|
||||
} from '../../../state/bookmark';
|
||||
import { bookmarkTemplate, categoryTemplate } from '../../../utility';
|
||||
import { ContentType } from '../Bookmarks';
|
||||
import { BookmarksForm } from './BookmarksForm';
|
||||
import { CategoryForm } from './CategoryForm';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
|
@ -16,26 +15,25 @@ interface Props {
|
|||
}
|
||||
|
||||
export const Form = (props: Props): JSX.Element => {
|
||||
const { categoryInEdit, bookmarkInEdit } = useSelector(
|
||||
(state: State) => state.bookmarks
|
||||
);
|
||||
const categoryInEdit = useAtomValue(categoryInEditAtom);
|
||||
const bookmarkInEdit = useAtomValue(bookmarkInEditAtom);
|
||||
|
||||
const { modalHandler, contentType, inUpdate } = props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
{!inUpdate ? (
|
||||
// form: add new
|
||||
<Fragment>
|
||||
<>
|
||||
{contentType === ContentType.category ? (
|
||||
<CategoryForm modalHandler={modalHandler} />
|
||||
) : (
|
||||
<BookmarksForm modalHandler={modalHandler} />
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
) : (
|
||||
// form: update
|
||||
<Fragment>
|
||||
<>
|
||||
{contentType === ContentType.category ? (
|
||||
<CategoryForm
|
||||
modalHandler={modalHandler}
|
||||
|
@ -47,8 +45,8 @@ export const Form = (props: Props): JSX.Element => {
|
|||
bookmark={bookmarkInEdit || bookmarkTemplate}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,42 +1,37 @@
|
|||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
Droppable,
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { Message, Table } from '../../UI';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import {
|
||||
categoryInEditAtom,
|
||||
useDeleteBookmark,
|
||||
useReorderBookmarks,
|
||||
useUpdateBookmark,
|
||||
} from '../../../state/bookmark';
|
||||
import { configAtom } from '../../../state/config';
|
||||
import { useCreateNotification } from '../../../state/notification';
|
||||
import { bookmarkTemplate } from '../../../utility';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import { Message, Table } from '../../UI';
|
||||
|
||||
interface Props {
|
||||
openFormForUpdating: (data: Category | Bookmark) => void;
|
||||
}
|
||||
|
||||
export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
||||
const {
|
||||
bookmarks: { categoryInEdit },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
const config = useAtomValue(configAtom);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
deleteBookmark,
|
||||
updateBookmark,
|
||||
createNotification,
|
||||
reorderBookmarks,
|
||||
} = bindActionCreators(actionCreators, dispatch);
|
||||
const categoryInEdit = useAtomValue(categoryInEditAtom);
|
||||
const deleteBookmark = useDeleteBookmark();
|
||||
const updateBookmark = useUpdateBookmark();
|
||||
const reorderBookmarks = useReorderBookmarks();
|
||||
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]);
|
||||
|
||||
|
@ -103,7 +98,7 @@ export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
{!categoryInEdit ? (
|
||||
<Message isPrimary={false}>
|
||||
Switch to grid view and click on the name of category you want to edit
|
||||
|
@ -183,6 +178,6 @@ export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
|||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,43 +1,38 @@
|
|||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
Droppable,
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { Message, Table } from '../../UI';
|
||||
import {
|
||||
categoriesAtom,
|
||||
useDeleteCategory,
|
||||
usePinCategory,
|
||||
useReorderCategories,
|
||||
useUpdateCategory,
|
||||
} from '../../../state/bookmark';
|
||||
import { configAtom } from '../../../state/config';
|
||||
import { useCreateNotification } from '../../../state/notification';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import { Message, Table } from '../../UI';
|
||||
|
||||
interface Props {
|
||||
openFormForUpdating: (data: Category | Bookmark) => void;
|
||||
}
|
||||
|
||||
export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
||||
const {
|
||||
config: { config },
|
||||
bookmarks: { categories },
|
||||
} = useSelector((state: State) => state);
|
||||
const config = useAtomValue(configAtom);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
pinCategory,
|
||||
deleteCategory,
|
||||
createNotification,
|
||||
reorderCategories,
|
||||
updateCategory,
|
||||
} = bindActionCreators(actionCreators, dispatch);
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const pinCategory = usePinCategory();
|
||||
const deleteCategory = useDeleteCategory();
|
||||
const reorderCategories = useReorderCategories();
|
||||
const updateCategory = useUpdateCategory();
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const [localCategories, setLocalCategories] = useState<Category[]>([]);
|
||||
|
||||
|
@ -95,7 +90,7 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Message isPrimary={false}>
|
||||
{config.useOrdering === 'orderId' ? (
|
||||
<p>You can drag and drop single rows to reorder categories</p>
|
||||
|
@ -161,6 +156,6 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
|||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,12 +16,14 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.SettingsLink {
|
||||
display: inline-block;
|
||||
visibility: visible;
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
|
|
|
@ -1,24 +1,14 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// CSS
|
||||
import classes from './Header.module.css';
|
||||
|
||||
// Components
|
||||
import { configAtom } from '../../../state/config';
|
||||
import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
|
||||
|
||||
// Utils
|
||||
import { getDateTime } from './functions/getDateTime';
|
||||
import { greeter } from './functions/greeter';
|
||||
import classes from './Header.module.css';
|
||||
|
||||
export const Header = (): JSX.Element => {
|
||||
const { hideHeader, hideDate, showTime } = useSelector(
|
||||
(state: State) => state.config.config
|
||||
);
|
||||
const { hideHeader, hideDate, showTime } = useAtomValue(configAtom);
|
||||
|
||||
const [dateTime, setDateTime] = useState<string>(getDateTime());
|
||||
const [greeting, setGreeting] = useState<string>(greeter());
|
||||
|
@ -36,12 +26,12 @@ export const Header = (): JSX.Element => {
|
|||
|
||||
return (
|
||||
<header className={classes.Header}>
|
||||
{(!hideDate || showTime) && <p>{dateTime}</p>}
|
||||
|
||||
<Link to="/settings" className={classes.SettingsLink}>
|
||||
Go to Settings
|
||||
</Link>
|
||||
|
||||
{(!hideDate || showTime) && <p>{dateTime}</p>}
|
||||
|
||||
{!hideHeader && (
|
||||
<span className={classes.HeaderMain}>
|
||||
<h1>{greeting}</h1>
|
||||
|
|
|
@ -48,23 +48,16 @@ export const getDateTime = (): string => {
|
|||
}
|
||||
|
||||
// Time
|
||||
const p = parseTime;
|
||||
let timeEl = '';
|
||||
|
||||
if (showTime) {
|
||||
const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p(
|
||||
now.getSeconds()
|
||||
)}`;
|
||||
|
||||
timeEl = time;
|
||||
timeEl = `${parseTime(now.getHours())}:${parseTime(
|
||||
now.getMinutes()
|
||||
)}:${parseTime(now.getSeconds())}`;
|
||||
}
|
||||
|
||||
// Separator
|
||||
let separator = '';
|
||||
|
||||
if (!hideDate && showTime) {
|
||||
separator = ' - ';
|
||||
}
|
||||
const separator = !hideDate && showTime ? ' · ' : '';
|
||||
|
||||
// Output
|
||||
return `${dateEl}${separator}${timeEl}`;
|
||||
|
|
|
@ -1,43 +1,35 @@
|
|||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
// Typescript
|
||||
import { App, Category } from '../../interfaces';
|
||||
|
||||
// UI
|
||||
import { Icon, Container, SectionHeadline, Spinner, Message } from '../UI';
|
||||
|
||||
// CSS
|
||||
import classes from './Home.module.css';
|
||||
|
||||
// Components
|
||||
import { appsAtom, appsLoadingAtom, useFetchApps } from '../../state/app';
|
||||
import { authAtom } from '../../state/auth';
|
||||
import {
|
||||
bookmarksLoadingAtom,
|
||||
categoriesAtom,
|
||||
useFetchCategories,
|
||||
} from '../../state/bookmark';
|
||||
import { configAtom } from '../../state/config';
|
||||
import { escapeRegex } from '../../utility';
|
||||
import { AppGrid } from '../Apps/AppGrid/AppGrid';
|
||||
import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||
import { SearchBar } from '../SearchBar/SearchBar';
|
||||
import { Container, Icon, Message, SectionHeadline, Spinner } from '../UI';
|
||||
import { Header } from './Header/Header';
|
||||
|
||||
// Utils
|
||||
import { escapeRegex } from '../../utility';
|
||||
import classes from './Home.module.css';
|
||||
|
||||
export const Home = (): JSX.Element => {
|
||||
const {
|
||||
apps: { apps, loading: appsLoading },
|
||||
bookmarks: { categories, loading: bookmarksLoading },
|
||||
config: { config },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
const config = useAtomValue(configAtom);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { getApps, getCategories } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
const apps = useAtomValue(appsAtom);
|
||||
const appsLoading = useAtomValue(appsLoadingAtom);
|
||||
const fetchApps = useFetchApps();
|
||||
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const bookmarksLoading = useAtomValue(bookmarksLoadingAtom);
|
||||
const fetchCategories = useFetchCategories();
|
||||
|
||||
// Local search query
|
||||
const [localSearch, setLocalSearch] = useState<null | string>(null);
|
||||
|
@ -46,17 +38,13 @@ export const Home = (): JSX.Element => {
|
|||
null | Category[]
|
||||
>(null);
|
||||
|
||||
// Load applications
|
||||
useEffect(() => {
|
||||
if (!apps.length) {
|
||||
getApps();
|
||||
fetchApps();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load bookmark categories
|
||||
useEffect(() => {
|
||||
if (!categories.length) {
|
||||
getCategories();
|
||||
fetchCategories();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
@ -110,12 +98,10 @@ export const Home = (): JSX.Element => {
|
|||
Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>,
|
||||
login and start customizing your new homepage
|
||||
</Message>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? (
|
||||
<Fragment>
|
||||
<>
|
||||
<SectionHeadline title="Applications" link="/applications" />
|
||||
{appsLoading ? (
|
||||
<Spinner />
|
||||
|
@ -131,14 +117,12 @@ export const Home = (): JSX.Element => {
|
|||
/>
|
||||
)}
|
||||
<div className={classes.HomeSpace}></div>
|
||||
</Fragment>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!config.hideCategories &&
|
||||
(isAuthenticated || categories.some((c) => c.isPinned)) ? (
|
||||
<Fragment>
|
||||
<>
|
||||
<SectionHeadline title="Bookmarks" link="/bookmarks" />
|
||||
{bookmarksLoading ? (
|
||||
<Spinner />
|
||||
|
@ -156,10 +140,8 @@ export const Home = (): JSX.Element => {
|
|||
fromHomepage={true}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Link to="/settings" className={classes.SettingsButton}>
|
||||
<Icon icon="mdiCog" color="var(--color-background)" />
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import { useSelector } from 'react-redux';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Notification as NotificationInterface } from '../../interfaces';
|
||||
|
||||
import { notificationsAtom } from '../../state/notification';
|
||||
import { Notification } from '../UI';
|
||||
import classes from './NotificationCenter.module.css';
|
||||
|
||||
import { Notification } from '../UI';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
export const NotificationCenter = (): JSX.Element => {
|
||||
const { notifications } = useSelector((state: State) => state.notification);
|
||||
const { notifications } = useAtomValue(notificationsAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { useSelector } from 'react-redux';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Redirect, Route, RouteProps } from 'react-router';
|
||||
import { State } from '../../store/reducers';
|
||||
import { authAtom } from '../../state/auth';
|
||||
|
||||
export const ProtectedRoute = ({ ...rest }: RouteProps) => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Route {...rest} />;
|
||||
|
|
|
@ -1,17 +1,21 @@
|
|||
.SearchProvider {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.SearchBar {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
color: var(--color-primary);
|
||||
/* font-size: 20px; */
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 80px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.SearchBar:focus {
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,11 @@
|
|||
import { useRef, useEffect, KeyboardEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { App, Category } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import { configAtom, configLoadingAtom } from '../../state/config';
|
||||
import { useCreateNotification } from '../../state/notification';
|
||||
import { redirectUrl, urlParser, useSearchParser } from '../../utility';
|
||||
import classes from './SearchBar.module.css';
|
||||
|
||||
// Utils
|
||||
import { searchParser, urlParser, redirectUrl } from '../../utility';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
interface Props {
|
||||
setLocalSearch: (query: string) => void;
|
||||
appSearchResult: App[] | null;
|
||||
|
@ -22,15 +13,20 @@ interface Props {
|
|||
}
|
||||
|
||||
export const SearchBar = (props: Props): JSX.Element => {
|
||||
const { config, loading } = useSelector((state: State) => state.config);
|
||||
const config = useAtomValue(configAtom);
|
||||
const loading = useAtomValue(configLoadingAtom);
|
||||
const searchParser = useSearchParser();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
|
||||
|
||||
const [searchProvider, setSearchProvider] = useState(
|
||||
searchParser('').primarySearch.name
|
||||
);
|
||||
|
||||
// Search bar autofocus
|
||||
useEffect(() => {
|
||||
if (!loading && !config.disableAutofocus) {
|
||||
|
@ -78,6 +74,10 @@ export const SearchBar = (props: Props): JSX.Element => {
|
|||
setLocalSearch(encodedURL);
|
||||
}
|
||||
|
||||
if (primarySearch.name) {
|
||||
setSearchProvider(primarySearch.name);
|
||||
}
|
||||
|
||||
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
|
||||
if (!primarySearch.prefix) {
|
||||
// Prefix not found -> emit notification
|
||||
|
@ -113,6 +113,7 @@ export const SearchBar = (props: Props): JSX.Element => {
|
|||
const url = `${primarySearch.template}${encodedURL}`;
|
||||
redirectUrl(url, sameTab);
|
||||
}
|
||||
if (config.autoClearSearch) clearSearch();
|
||||
} else if (e.code === 'Escape') {
|
||||
clearSearch();
|
||||
}
|
||||
|
@ -120,6 +121,9 @@ export const SearchBar = (props: Props): JSX.Element => {
|
|||
|
||||
return (
|
||||
<div className={classes.SearchContainer}>
|
||||
{!config.hideSearchProvider && (
|
||||
<span className={classes.SearchProvider}>{searchProvider}</span>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
|
|
@ -1,34 +1,28 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
// UI
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { authAtom } from '../../../state/auth';
|
||||
import { useCheckVersion } from '../../../utility';
|
||||
import { Button, SettingsHeadline } from '../../UI';
|
||||
import { AuthForm } from './AuthForm/AuthForm';
|
||||
import classes from './AppDetails.module.css';
|
||||
|
||||
// Store
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// Other
|
||||
import { checkVersion } from '../../../utility';
|
||||
import { AuthForm } from './AuthForm/AuthForm';
|
||||
|
||||
export const AppDetails = (): JSX.Element => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
const checkVersion = useCheckVersion(true);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<SettingsHeadline text="Authentication" />
|
||||
<AuthForm />
|
||||
|
||||
{isAuthenticated && (
|
||||
<Fragment>
|
||||
<>
|
||||
<hr className={classes.separator} />
|
||||
|
||||
<div>
|
||||
<SettingsHeadline text="App version" />
|
||||
<p className={classes.text}>
|
||||
<a
|
||||
href="https://github.com/pawelmalak/flame"
|
||||
href="https://github.com/GeorgeSG/flame"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
@ -40,7 +34,7 @@ export const AppDetails = (): JSX.Element => {
|
|||
<p className={classes.text}>
|
||||
See changelog{' '}
|
||||
<a
|
||||
href="https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md"
|
||||
href="https://github.com/GeorgeSG/flame/blob/master/CHANGELOG.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
@ -48,10 +42,10 @@ export const AppDetails = (): JSX.Element => {
|
|||
</a>
|
||||
</p>
|
||||
|
||||
<Button click={() => checkVersion(true)}>Check for updates</Button>
|
||||
<Button click={checkVersion}>Check for updates</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,21 +1,14 @@
|
|||
import { FormEvent, Fragment, useEffect, useState, useRef } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { authAtom, useLogin, useLogout } from '../../../../state/auth';
|
||||
import { decodeToken, parseTokenExpire } from '../../../../utility';
|
||||
|
||||
// Other
|
||||
import { InputGroup, Button } from '../../../UI';
|
||||
import { Button, InputGroup } from '../../../UI';
|
||||
import classes from '../AppDetails.module.css';
|
||||
|
||||
export const AuthForm = (): JSX.Element => {
|
||||
const { isAuthenticated, token } = useSelector((state: State) => state.auth);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { login, logout } = bindActionCreators(actionCreators, dispatch);
|
||||
const { isAuthenticated, token } = useAtomValue(authAtom);
|
||||
const login = useLogin();
|
||||
const logout = useLogout();
|
||||
|
||||
const [tokenExpires, setTokenExpires] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
|
@ -47,7 +40,7 @@ export const AuthForm = (): JSX.Element => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
{!isAuthenticated ? (
|
||||
<form onSubmit={formHandler}>
|
||||
<InputGroup>
|
||||
|
@ -105,6 +98,6 @@ export const AuthForm = (): JSX.Element => {
|
|||
<Button click={logout}>Logout</Button>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,25 +1,19 @@
|
|||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { DockerSettingsForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { InputGroup, Button, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, dockerSettingsTemplate } from '../../../utility';
|
||||
import {
|
||||
configAtom,
|
||||
configLoadingAtom,
|
||||
useUpdateConfig,
|
||||
} from '../../../state/config';
|
||||
import { dockerSettingsTemplate, inputHandler } from '../../../utility';
|
||||
import { Button, InputGroup, SettingsHeadline } from '../../UI';
|
||||
import { Checkbox } from '../../UI/Checkbox/Checkbox';
|
||||
|
||||
export const DockerSettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
|
||||
const loading = useAtomValue(configLoadingAtom);
|
||||
const config = useAtomValue(configAtom);
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<DockerSettingsForm>(
|
||||
|
@ -54,6 +48,9 @@ export const DockerSettings = (): JSX.Element => {
|
|||
});
|
||||
};
|
||||
|
||||
const onBooleanToggle = (prop: keyof DockerSettingsForm) =>
|
||||
setFormData((prev) => ({ ...prev, [prop]: !prev[prop] }));
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
<SettingsHeadline text="Docker" />
|
||||
|
@ -71,49 +68,37 @@ export const DockerSettings = (): JSX.Element => {
|
|||
</InputGroup>
|
||||
|
||||
{/* USE DOCKER API */}
|
||||
<InputGroup>
|
||||
<label htmlFor="dockerApps">Use Docker API</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="dockerApps"
|
||||
name="dockerApps"
|
||||
value={formData.dockerApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
checked={formData.dockerApps}
|
||||
onClick={() => onBooleanToggle('dockerApps')}
|
||||
/>
|
||||
<label htmlFor="dockerApps">Use Docker API</label>
|
||||
</InputGroup>
|
||||
|
||||
{/* UNPIN DOCKER APPS */}
|
||||
<InputGroup>
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="unpinStoppedApps"
|
||||
checked={formData.unpinStoppedApps}
|
||||
onClick={() => onBooleanToggle('unpinStoppedApps')}
|
||||
/>
|
||||
<label htmlFor="unpinStoppedApps">
|
||||
Unpin stopped containers / other apps
|
||||
</label>
|
||||
<select
|
||||
id="unpinStoppedApps"
|
||||
name="unpinStoppedApps"
|
||||
value={formData.unpinStoppedApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* KUBERNETES SETTINGS */}
|
||||
<SettingsHeadline text="Kubernetes" />
|
||||
{/* USE KUBERNETES */}
|
||||
<InputGroup>
|
||||
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="kubernetesApps"
|
||||
name="kubernetesApps"
|
||||
value={formData.kubernetesApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
checked={formData.kubernetesApps}
|
||||
onClick={() => onBooleanToggle('kubernetesApps')}
|
||||
/>
|
||||
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
|
|
|
@ -1,28 +1,17 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Query } from '../../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { Modal, Icon, Button, CompactTable, ActionIcons } from '../../../UI';
|
||||
|
||||
// Components
|
||||
import { configAtom } from '../../../../state/config';
|
||||
import { useCreateNotification } from '../../../../state/notification';
|
||||
import { customQueriesAtom, useDeleteQuery } from '../../../../state/queries';
|
||||
import { ActionIcons, Button, CompactTable, Icon, Modal } from '../../../UI';
|
||||
import { QueriesForm } from './QueriesForm';
|
||||
|
||||
export const CustomQueries = (): JSX.Element => {
|
||||
const { customQueries, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { deleteQuery, createNotification } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
const customQueries = useAtomValue(customQueriesAtom);
|
||||
const deleteQuery = useDeleteQuery();
|
||||
const config = useAtomValue(configAtom);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
|
||||
|
@ -49,7 +38,7 @@ export const CustomQueries = (): JSX.Element => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<Modal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={() => setModalIsOpen(!modalIsOpen)}
|
||||
|
@ -82,9 +71,7 @@ export const CustomQueries = (): JSX.Element => {
|
|||
</Fragment>
|
||||
))}
|
||||
</CompactTable>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
click={() => {
|
||||
|
@ -95,6 +82,6 @@ export const CustomQueries = (): JSX.Element => {
|
|||
Add new search provider
|
||||
</Button>
|
||||
</section>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { Query } from '../../../../interfaces';
|
||||
|
||||
import { useAddQuery, useUpdateQuery } from '../../../../state/queries';
|
||||
import { Button, InputGroup, ModalForm } from '../../../UI';
|
||||
|
||||
interface Props {
|
||||
|
@ -14,11 +9,8 @@ interface Props {
|
|||
}
|
||||
|
||||
export const QueriesForm = (props: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { addQuery, updateQuery } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
const addQuery = useAddQuery();
|
||||
const updateQuery = useUpdateQuery();
|
||||
|
||||
const { modalHandler, query } = props;
|
||||
|
||||
|
|
|
@ -1,36 +1,36 @@
|
|||
// React
|
||||
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { Query, GeneralForm } from '../../../interfaces';
|
||||
|
||||
// Components
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { GeneralForm, Query } from '../../../interfaces';
|
||||
import { useSetSortedApps } from '../../../state/app';
|
||||
import {
|
||||
categoriesAtom,
|
||||
useSetSortedCategories,
|
||||
useSetSortedBookmarks,
|
||||
} from '../../../state/bookmark';
|
||||
import {
|
||||
configAtom,
|
||||
configLoadingAtom,
|
||||
useUpdateConfig,
|
||||
} from '../../../state/config';
|
||||
import { customQueriesAtom } from '../../../state/queries';
|
||||
import { generalSettingsTemplate, inputHandler } from '../../../utility';
|
||||
import { queries } from '../../../utility/searchQueries.json';
|
||||
import { Button, InputGroup, SettingsHeadline } from '../../UI';
|
||||
import { Checkbox } from '../../UI/Checkbox/Checkbox';
|
||||
import { CustomQueries } from './CustomQueries/CustomQueries';
|
||||
|
||||
// UI
|
||||
import { Button, SettingsHeadline, InputGroup } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, generalSettingsTemplate } from '../../../utility';
|
||||
|
||||
// Data
|
||||
import { queries } from '../../../utility/searchQueries.json';
|
||||
|
||||
// Redux
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
export const GeneralSettings = (): JSX.Element => {
|
||||
const {
|
||||
config: { loading, customQueries, config },
|
||||
bookmarks: { categories },
|
||||
} = useSelector((state: State) => state);
|
||||
const config = useAtomValue(configAtom);
|
||||
const loading = useAtomValue(configLoadingAtom);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig, sortApps, sortCategories, sortBookmarks } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
const updateConfig = useUpdateConfig();
|
||||
const customQueries = useAtomValue(customQueriesAtom);
|
||||
|
||||
const setSortedApps = useSetSortedApps();
|
||||
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const setSortedCategories = useSetSortedCategories();
|
||||
const setSortedBookmarks = useSetSortedBookmarks();
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<GeneralForm>(
|
||||
|
@ -53,15 +53,18 @@ export const GeneralSettings = (): JSX.Element => {
|
|||
|
||||
// Sort entities with new settings
|
||||
if (formData.useOrdering !== config.useOrdering) {
|
||||
sortApps();
|
||||
sortCategories();
|
||||
setSortedApps();
|
||||
setSortedCategories();
|
||||
|
||||
for (let { id } of categories) {
|
||||
sortBookmarks(id);
|
||||
setSortedBookmarks(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onBooleanToggle = (prop: keyof GeneralForm) =>
|
||||
setFormData((prev) => ({ ...prev, [prop]: !prev[prop] }));
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
|
@ -76,7 +79,7 @@ export const GeneralSettings = (): JSX.Element => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<form
|
||||
onSubmit={(e) => formSubmitHandler(e)}
|
||||
style={{ marginBottom: '30px' }}
|
||||
|
@ -101,67 +104,52 @@ export const GeneralSettings = (): JSX.Element => {
|
|||
{/* === APPS OPTIONS === */}
|
||||
<SettingsHeadline text="Apps" />
|
||||
{/* PIN APPS */}
|
||||
<InputGroup>
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="pinAppsByDefault"
|
||||
name="pinAppsByDefault"
|
||||
checked={formData.pinAppsByDefault}
|
||||
onClick={() => onBooleanToggle('pinAppsByDefault')}
|
||||
/>
|
||||
<label htmlFor="pinAppsByDefault">
|
||||
Pin new applications by default
|
||||
</label>
|
||||
<select
|
||||
id="pinAppsByDefault"
|
||||
name="pinAppsByDefault"
|
||||
value={formData.pinAppsByDefault ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* APPS OPPENING */}
|
||||
<InputGroup>
|
||||
<label htmlFor="appsSameTab">Open applications in the same tab</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="appsSameTab"
|
||||
name="appsSameTab"
|
||||
value={formData.appsSameTab ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
checked={formData.appsSameTab}
|
||||
onClick={() => onBooleanToggle('appsSameTab')}
|
||||
/>
|
||||
<label htmlFor="appsSameTab">Open applications in the same tab</label>
|
||||
</InputGroup>
|
||||
|
||||
{/* === BOOKMARKS OPTIONS === */}
|
||||
<SettingsHeadline text="Bookmarks" />
|
||||
{/* PIN CATEGORIES */}
|
||||
<InputGroup>
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="pinCategoriesByDefault"
|
||||
checked={formData.pinCategoriesByDefault}
|
||||
onClick={() => onBooleanToggle('pinCategoriesByDefault')}
|
||||
/>
|
||||
<label htmlFor="pinCategoriesByDefault">
|
||||
Pin new categories by default
|
||||
</label>
|
||||
<select
|
||||
id="pinCategoriesByDefault"
|
||||
name="pinCategoriesByDefault"
|
||||
value={formData.pinCategoriesByDefault ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* BOOKMARKS OPPENING */}
|
||||
<InputGroup>
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="bookmarksSameTab"
|
||||
checked={formData.bookmarksSameTab}
|
||||
onClick={() => onBooleanToggle('bookmarksSameTab')}
|
||||
/>
|
||||
<label htmlFor="bookmarksSameTab">
|
||||
Open bookmarks in the same tab
|
||||
</label>
|
||||
<select
|
||||
id="bookmarksSameTab"
|
||||
name="bookmarksSameTab"
|
||||
value={formData.bookmarksSameTab ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* === SEARCH OPTIONS === */}
|
||||
|
@ -214,19 +202,15 @@ export const GeneralSettings = (): JSX.Element => {
|
|||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup>
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="searchSameTab"
|
||||
checked={formData.searchSameTab}
|
||||
onClick={() => onBooleanToggle('searchSameTab')}
|
||||
/>
|
||||
<label htmlFor="searchSameTab">
|
||||
Open search results in the same tab
|
||||
</label>
|
||||
<select
|
||||
id="searchSameTab"
|
||||
name="searchSameTab"
|
||||
value={formData.searchSameTab ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
|
@ -235,6 +219,6 @@ export const GeneralSettings = (): JSX.Element => {
|
|||
{/* CUSTOM QUERIES */}
|
||||
<SettingsHeadline text="Custom search providers" />
|
||||
<CustomQueries />
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
align-items: center;
|
||||
height: 40px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.SettingsNavLink:hover,
|
||||
|
@ -37,4 +38,4 @@
|
|||
.Settings {
|
||||
grid-template-columns: 1fr 3fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,33 +1,21 @@
|
|||
import { NavLink, Link, Switch, Route } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Link, NavLink, Route, Switch } from 'react-router-dom';
|
||||
import { Route as SettingsRoute } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './Settings.module.css';
|
||||
|
||||
// Components
|
||||
import { Themer } from './Themer/Themer';
|
||||
import { WeatherSettings } from './WeatherSettings/WeatherSettings';
|
||||
import { UISettings } from './UISettings/UISettings';
|
||||
import { AppDetails } from './AppDetails/AppDetails';
|
||||
import { StyleSettings } from './StyleSettings/StyleSettings';
|
||||
import { GeneralSettings } from './GeneralSettings/GeneralSettings';
|
||||
import { DockerSettings } from './DockerSettings/DockerSettings';
|
||||
import { authAtom } from '../../state/auth';
|
||||
import { ProtectedRoute } from '../Routing/ProtectedRoute';
|
||||
|
||||
// UI
|
||||
import { Container, Headline } from '../UI';
|
||||
|
||||
// Data
|
||||
import { AppDetails } from './AppDetails/AppDetails';
|
||||
import { DockerSettings } from './DockerSettings/DockerSettings';
|
||||
import { GeneralSettings } from './GeneralSettings/GeneralSettings';
|
||||
import { routes } from './settings.json';
|
||||
import classes from './Settings.module.css';
|
||||
import { StyleSettings } from './StyleSettings/StyleSettings';
|
||||
import { Themer } from './Themer/Themer';
|
||||
import { UISettings } from './UISettings/UISettings';
|
||||
import { WeatherSettings } from './WeatherSettings/WeatherSettings';
|
||||
|
||||
export const Settings = (): JSX.Element => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);
|
||||
|
||||
|
|
|
@ -1,21 +1,12 @@
|
|||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { ApiResponse } from '../../../interfaces';
|
||||
|
||||
// Other
|
||||
import { InputGroup, Button } from '../../UI';
|
||||
import { useCreateNotification } from '../../../state/notification';
|
||||
import { applyAuth } from '../../../utility';
|
||||
import { Button, InputGroup } from '../../UI';
|
||||
|
||||
export const StyleSettings = (): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
const [customStyles, setCustomStyles] = useState<string>('');
|
||||
|
||||
|
|
|
@ -1,15 +1,8 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
import { State } from '../../../../store/reducers';
|
||||
|
||||
// Other
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Theme } from '../../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { authAtom } from '../../../../state/auth';
|
||||
import { themeInEditAtom, userThemesAtom } from '../../../../state/theme';
|
||||
import { Button, Modal } from '../../../UI';
|
||||
import { ThemeGrid } from '../ThemeGrid/ThemeGrid';
|
||||
import classes from './ThemeBuilder.module.css';
|
||||
|
@ -21,16 +14,26 @@ interface Props {
|
|||
}
|
||||
|
||||
export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
|
||||
const {
|
||||
auth: { isAuthenticated },
|
||||
theme: { themeInEdit, userThemes },
|
||||
} = useSelector((state: State) => state);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
const { editTheme } = bindActionCreators(actionCreators, useDispatch());
|
||||
const [themeInEdit, setThemeInEdit] = useAtom(themeInEditAtom);
|
||||
const userThemes = useAtomValue(userThemesAtom);
|
||||
|
||||
const [showModal, toggleShowModal] = useState(false);
|
||||
const [isInEdit, toggleIsInEdit] = useState(false);
|
||||
|
||||
const showEdit = () => {
|
||||
toggleIsInEdit(true);
|
||||
toggleShowModal(true);
|
||||
};
|
||||
|
||||
const showCreate = () => {
|
||||
setThemeInEdit(null);
|
||||
toggleIsInEdit(false);
|
||||
toggleShowModal(true);
|
||||
};
|
||||
|
||||
// TODO: Refactor all useEffects to simplify
|
||||
useEffect(() => {
|
||||
if (themeInEdit) {
|
||||
toggleIsInEdit(false);
|
||||
|
@ -51,7 +54,7 @@ export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
|
|||
<Modal
|
||||
isOpen={showModal}
|
||||
setIsOpen={() => toggleShowModal(!showModal)}
|
||||
cb={() => editTheme(null)}
|
||||
cb={() => setThemeInEdit(null)}
|
||||
>
|
||||
{isInEdit ? (
|
||||
<ThemeEditor modalHandler={() => toggleShowModal(!showModal)} />
|
||||
|
@ -66,28 +69,10 @@ export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
|
|||
{/* BUTTONS */}
|
||||
{isAuthenticated && (
|
||||
<div className={classes.Buttons}>
|
||||
<Button
|
||||
click={() => {
|
||||
editTheme(null);
|
||||
toggleIsInEdit(false);
|
||||
toggleShowModal(!showModal);
|
||||
}}
|
||||
>
|
||||
Create new theme
|
||||
</Button>
|
||||
|
||||
<Button click={showCreate}>Create new theme</Button>
|
||||
{themes.length ? (
|
||||
<Button
|
||||
click={() => {
|
||||
toggleIsInEdit(true);
|
||||
toggleShowModal(!showModal);
|
||||
}}
|
||||
>
|
||||
Edit user themes
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<Button click={showEdit}>Edit user themes</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,31 +1,24 @@
|
|||
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
import { State } from '../../../../store/reducers';
|
||||
|
||||
// UI
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { Theme } from '../../../../interfaces';
|
||||
import {
|
||||
activeThemeAtom,
|
||||
themeInEditAtom,
|
||||
useAddTheme,
|
||||
useUpdateTheme,
|
||||
} from '../../../../state/theme';
|
||||
import { Button, InputGroup, ModalForm } from '../../../UI';
|
||||
import classes from './ThemeCreator.module.css';
|
||||
|
||||
// Other
|
||||
import { Theme } from '../../../../interfaces';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
}
|
||||
|
||||
export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
|
||||
const {
|
||||
theme: { activeTheme, themeInEdit },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const { addTheme, updateTheme, editTheme } = bindActionCreators(
|
||||
actionCreators,
|
||||
useDispatch()
|
||||
);
|
||||
const activeTheme = useAtomValue(activeThemeAtom);
|
||||
const [themeInEdit, setThemeInEdit] = useAtom(themeInEditAtom);
|
||||
const addTheme = useAddTheme();
|
||||
const updateTheme = useUpdateTheme();
|
||||
|
||||
const [formData, setFormData] = useState<Theme>({
|
||||
name: '',
|
||||
|
@ -37,9 +30,10 @@ export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFormData({ ...formData, colors: activeTheme.colors });
|
||||
}, [activeTheme]);
|
||||
useEffect(
|
||||
() => setFormData({ ...formData, colors: activeTheme.colors }),
|
||||
[activeTheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (themeInEdit) {
|
||||
|
@ -69,7 +63,7 @@ export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
|
|||
};
|
||||
|
||||
const closeModal = () => {
|
||||
editTheme(null);
|
||||
setThemeInEdit(null);
|
||||
modalHandler();
|
||||
};
|
||||
|
||||
|
|
|
@ -1,32 +1,25 @@
|
|||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { Theme } from '../../../../interfaces';
|
||||
import { actionCreators } from '../../../../store';
|
||||
import { State } from '../../../../store/reducers';
|
||||
|
||||
// Other
|
||||
import {
|
||||
themeInEditAtom,
|
||||
useDeleteTheme,
|
||||
userThemesAtom,
|
||||
} from '../../../../state/theme';
|
||||
import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
}
|
||||
|
||||
export const ThemeEditor = (props: Props): JSX.Element => {
|
||||
const {
|
||||
theme: { userThemes },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const { deleteTheme, editTheme } = bindActionCreators(
|
||||
actionCreators,
|
||||
useDispatch()
|
||||
);
|
||||
export const ThemeEditor = ({ modalHandler }: Props): JSX.Element => {
|
||||
const userThemes = useAtomValue(userThemesAtom);
|
||||
const setThemeInEdit = useSetAtom(themeInEditAtom);
|
||||
const deleteTheme = useDeleteTheme();
|
||||
|
||||
const updateHandler = (theme: Theme) => {
|
||||
props.modalHandler();
|
||||
editTheme(theme);
|
||||
modalHandler();
|
||||
setThemeInEdit(theme);
|
||||
};
|
||||
|
||||
const deleteHandler = (theme: Theme) => {
|
||||
|
@ -36,7 +29,7 @@ export const ThemeEditor = (props: Props): JSX.Element => {
|
|||
};
|
||||
|
||||
return (
|
||||
<ModalForm formHandler={() => {}} modalHandler={props.modalHandler}>
|
||||
<ModalForm formHandler={() => {}} modalHandler={modalHandler}>
|
||||
<CompactTable headers={['Name', 'Actions']}>
|
||||
{userThemes.map((t, idx) => (
|
||||
<Fragment key={idx}>
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
// Components
|
||||
import { ThemePreview } from '../ThemePreview/ThemePreview';
|
||||
|
||||
// Other
|
||||
import { Theme } from '../../../../interfaces';
|
||||
import { ThemePreview } from '../ThemePreview/ThemePreview';
|
||||
import classes from './ThemeGrid.module.css';
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
// Redux
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
// Other
|
||||
import { Theme } from '../../../../interfaces/Theme';
|
||||
import { useSetTheme } from '../../../../state/theme';
|
||||
import classes from './ThemePreview.module.css';
|
||||
|
||||
interface Props {
|
||||
|
@ -14,7 +9,7 @@ interface Props {
|
|||
export const ThemePreview = ({
|
||||
theme: { colors, name },
|
||||
}: Props): JSX.Element => {
|
||||
const { setTheme } = bindActionCreators(actionCreators, useDispatch());
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
return (
|
||||
<div className={classes.ThemePreview} onClick={() => setTheme(colors)}>
|
||||
|
|
|
@ -1,35 +1,31 @@
|
|||
import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { Theme, ThemeSettingsForm } from '../../../interfaces';
|
||||
|
||||
// Components
|
||||
import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
|
||||
import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
|
||||
import { ThemeGrid } from './ThemeGrid/ThemeGrid';
|
||||
|
||||
// Other
|
||||
import { authAtom } from '../../../state/auth';
|
||||
import {
|
||||
configAtom,
|
||||
configLoadingAtom,
|
||||
useUpdateConfig,
|
||||
} from '../../../state/config';
|
||||
import { themesAtom, userThemesAtom } from '../../../state/theme';
|
||||
import {
|
||||
inputHandler,
|
||||
parseThemeToPAB,
|
||||
themeSettingsTemplate,
|
||||
} from '../../../utility';
|
||||
import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
|
||||
import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
|
||||
import { ThemeGrid } from './ThemeGrid/ThemeGrid';
|
||||
|
||||
export const Themer = (): JSX.Element => {
|
||||
const {
|
||||
auth: { isAuthenticated },
|
||||
config: { loading, config },
|
||||
theme: { themes, userThemes },
|
||||
} = useSelector((state: State) => state);
|
||||
const { isAuthenticated } = useAtomValue(authAtom);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
|
||||
const themes = useAtomValue(themesAtom);
|
||||
const userThemes = useAtomValue(userThemesAtom);
|
||||
|
||||
const loading = useAtomValue(configLoadingAtom);
|
||||
const config = useAtomValue(configAtom);
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<ThemeSettingsForm>(
|
||||
|
@ -37,11 +33,7 @@ export const Themer = (): JSX.Element => {
|
|||
);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
...config,
|
||||
});
|
||||
}, [loading]);
|
||||
useEffect(() => setFormData({ ...config }), [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
|
@ -65,14 +57,14 @@ export const Themer = (): JSX.Element => {
|
|||
};
|
||||
|
||||
const customThemesEl = (
|
||||
<Fragment>
|
||||
<>
|
||||
<SettingsHeadline text="User themes" />
|
||||
<ThemeBuilder themes={userThemes} />
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<SettingsHeadline text="App themes" />
|
||||
{!themes.length ? <Spinner /> : <ThemeGrid themes={themes} />}
|
||||
|
||||
|
@ -100,6 +92,6 @@ export const Themer = (): JSX.Element => {
|
|||
<Button>Save changes</Button>
|
||||
</form>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,25 +1,19 @@
|
|||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { UISettingsForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { InputGroup, Button, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { uiSettingsTemplate, inputHandler } from '../../../utility';
|
||||
import {
|
||||
configAtom,
|
||||
configLoadingAtom,
|
||||
useUpdateConfig,
|
||||
} from '../../../state/config';
|
||||
import { inputHandler, uiSettingsTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, SettingsHeadline } from '../../UI';
|
||||
import { Checkbox } from '../../UI/Checkbox/Checkbox';
|
||||
|
||||
export const UISettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
|
||||
const loading = useAtomValue(configLoadingAtom);
|
||||
const config = useAtomValue(configAtom);
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<UISettingsForm>(uiSettingsTemplate);
|
||||
|
@ -55,6 +49,9 @@ export const UISettings = (): JSX.Element => {
|
|||
});
|
||||
};
|
||||
|
||||
const onBooleanToggle = (prop: keyof UISettingsForm) =>
|
||||
setFormData((prev) => ({ ...prev, [prop]: !prev[prop] }));
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
{/* === OTHER OPTIONS === */}
|
||||
|
@ -75,77 +72,77 @@ export const UISettings = (): JSX.Element => {
|
|||
{/* === SEARCH OPTIONS === */}
|
||||
<SettingsHeadline text="Search" />
|
||||
{/* HIDE SEARCHBAR */}
|
||||
<InputGroup>
|
||||
<label htmlFor="hideSearch">Hide search bar</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="hideSearch"
|
||||
name="hideSearch"
|
||||
value={formData.hideSearch ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
checked={formData.hideSearch}
|
||||
onClick={() => onBooleanToggle('hideSearch')}
|
||||
/>
|
||||
<label htmlFor="hideSearch">Hide search bar</label>
|
||||
</InputGroup>
|
||||
{/* HIDE SEARCH PROVIDER*/}
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="hideSearchProvider"
|
||||
checked={formData.hideSearchProvider}
|
||||
onClick={() => onBooleanToggle('hideSearchProvider')}
|
||||
/>
|
||||
<label htmlFor="hideSearchProvider">Hide search provider label</label>
|
||||
</InputGroup>
|
||||
|
||||
{/* AUTOFOCUS SEARCHBAR */}
|
||||
<InputGroup>
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="appsSameTab"
|
||||
checked={formData.disableAutofocus}
|
||||
onClick={() => onBooleanToggle('disableAutofocus')}
|
||||
/>
|
||||
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
|
||||
<select
|
||||
id="disableAutofocus"
|
||||
name="disableAutofocus"
|
||||
value={formData.disableAutofocus ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="autoClearSearch"
|
||||
checked={formData.autoClearSearch}
|
||||
onClick={() => onBooleanToggle('autoClearSearch')}
|
||||
/>
|
||||
<label htmlFor="autoClearSearch">
|
||||
Automatically clear the search bar
|
||||
</label>
|
||||
</InputGroup>
|
||||
|
||||
{/* === HEADER OPTIONS === */}
|
||||
<SettingsHeadline text="Header" />
|
||||
{/* HIDE HEADER */}
|
||||
<InputGroup>
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="hideHeader"
|
||||
checked={formData.hideHeader}
|
||||
onClick={() => onBooleanToggle('hideHeader')}
|
||||
/>
|
||||
<label htmlFor="hideHeader">
|
||||
Hide headline (greetings and weather)
|
||||
</label>
|
||||
<select
|
||||
id="hideHeader"
|
||||
name="hideHeader"
|
||||
value={formData.hideHeader ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* HIDE DATE */}
|
||||
<InputGroup>
|
||||
<label htmlFor="hideDate">Hide date</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="hideDate"
|
||||
name="hideDate"
|
||||
value={formData.hideDate ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
checked={formData.hideDate}
|
||||
onClick={() => onBooleanToggle('hideDate')}
|
||||
/>
|
||||
<label htmlFor="hideDate">Hide date</label>
|
||||
</InputGroup>
|
||||
|
||||
{/* HIDE TIME */}
|
||||
<InputGroup>
|
||||
<label htmlFor="showTime">Hide time</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="showTime"
|
||||
name="showTime"
|
||||
value={formData.showTime ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={0}>True</option>
|
||||
<option value={1}>False</option>
|
||||
</select>
|
||||
checked={!formData.showTime}
|
||||
onClick={() => onBooleanToggle('showTime')}
|
||||
/>
|
||||
<label htmlFor="showTime">Hide time</label>
|
||||
</InputGroup>
|
||||
|
||||
{/* DATE FORMAT */}
|
||||
|
@ -210,31 +207,23 @@ export const UISettings = (): JSX.Element => {
|
|||
{/* === SECTIONS OPTIONS === */}
|
||||
<SettingsHeadline text="Sections" />
|
||||
{/* HIDE APPS */}
|
||||
<InputGroup>
|
||||
<label htmlFor="hideApps">Hide applications</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="hideApps"
|
||||
name="hideApps"
|
||||
value={formData.hideApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
checked={formData.hideApps}
|
||||
onClick={() => onBooleanToggle('hideApps')}
|
||||
/>
|
||||
<label htmlFor="hideApps">Hide applications</label>
|
||||
</InputGroup>
|
||||
|
||||
{/* HIDE CATEGORIES */}
|
||||
<InputGroup>
|
||||
<label htmlFor="hideCategories">Hide categories</label>
|
||||
<select
|
||||
<InputGroup type="horizontal">
|
||||
<Checkbox
|
||||
id="hideCategories"
|
||||
name="hideCategories"
|
||||
value={formData.hideCategories ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
checked={formData.hideCategories}
|
||||
onClick={() => onBooleanToggle('hideCategories')}
|
||||
/>
|
||||
<label htmlFor="hideCategories">Hide categories</label>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
|
|
|
@ -1,29 +1,22 @@
|
|||
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// Typescript
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { InputGroup, Button, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import {
|
||||
configAtom,
|
||||
configLoadingAtom,
|
||||
useUpdateConfig,
|
||||
} from '../../../state/config';
|
||||
import { useCreateNotification } from '../../../state/notification';
|
||||
import { inputHandler, weatherSettingsTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, SettingsHeadline } from '../../UI';
|
||||
|
||||
export const WeatherSettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
const config = useAtomValue(configAtom);
|
||||
const loading = useAtomValue(configLoadingAtom);
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification, updateConfig } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<WeatherForm>(
|
||||
|
@ -110,10 +103,10 @@ export const WeatherSettings = (): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
<span>
|
||||
Using
|
||||
<a href="https://www.weatherapi.com/pricing.aspx" target="blank">
|
||||
Now using
|
||||
<a href="https://openweathermap.org/api" target="blank">
|
||||
{' '}
|
||||
Weather API
|
||||
OpenWeatherMap
|
||||
</a>
|
||||
. Key is required for weather module to work.
|
||||
</span>
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { Fragment } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classes from './ActionButton.module.css';
|
||||
import { Icon } from '../..';
|
||||
import classes from './ActionButton.module.css';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
|
@ -13,12 +12,12 @@ interface Props {
|
|||
|
||||
export const ActionButton = (props: Props): JSX.Element => {
|
||||
const body = (
|
||||
<Fragment>
|
||||
<>
|
||||
<div className={classes.ActionButtonIcon}>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
<div className={classes.ActionButtonName}>{props.name}</div>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
|
||||
if (props.link) {
|
||||
|
|
26
client/src/components/UI/Checkbox/Checkbox.module.css
Normal file
26
client/src/components/UI/Checkbox/Checkbox.module.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
.Checkbox {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
margin: 8px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Checkbox.Checked::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: var(--color-background);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.Checkbox > input {
|
||||
visibility: hidden;
|
||||
}
|
25
client/src/components/UI/Checkbox/Checkbox.tsx
Normal file
25
client/src/components/UI/Checkbox/Checkbox.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import C from 'classnames';
|
||||
import classes from './Checkbox.module.css';
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onClick(): void;
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const Checkbox = ({
|
||||
id,
|
||||
name,
|
||||
checked,
|
||||
onClick,
|
||||
}: Props): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={C(classes.Checkbox, { [classes.Checked]: checked })}
|
||||
onClick={onClick}
|
||||
>
|
||||
<input type="checkbox" {...{ id, name }} />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -2,6 +2,16 @@
|
|||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.InputGroup.Horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.InputGroup label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.InputGroup label,
|
||||
.InputGroup span,
|
||||
.InputGroup input,
|
||||
|
|
|
@ -1,10 +1,23 @@
|
|||
import C from 'classnames';
|
||||
import { ReactNode } from 'react';
|
||||
import classes from './InputGroup.module.css';
|
||||
|
||||
interface Props {
|
||||
type?: 'vertical' | 'horizontal';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const InputGroup = (props: Props): JSX.Element => {
|
||||
return <div className={classes.InputGroup}>{props.children}</div>;
|
||||
export const InputGroup = ({
|
||||
type = 'vertical',
|
||||
children,
|
||||
}: Props): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={C(classes.InputGroup, {
|
||||
[classes.Horizontal]: type === 'horizontal',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
|
||||
import classes from './ModalForm.module.css';
|
||||
import { Icon } from '../..';
|
||||
import classes from './ModalForm.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
children: ReactNode;
|
||||
|
|
|
@ -8,11 +8,11 @@ interface Props {
|
|||
|
||||
export const Headline = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<h1 className={classes.HeadlineTitle}>{props.title}</h1>
|
||||
{props.subtitle && (
|
||||
<p className={classes.HeadlineSubtitle}>{props.subtitle}</p>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,4 +4,9 @@
|
|||
font-weight: 900;
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.SectionHeadlineLink:hover .SectionHeadline {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classes from './SectionHeadline.module.css';
|
||||
|
||||
interface Props {
|
||||
|
@ -9,7 +8,7 @@ interface Props {
|
|||
|
||||
export const SectionHeadline = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<Link to={props.link}>
|
||||
<Link to={props.link} className={classes.SectionHeadlineLink}>
|
||||
<h2 className={classes.SectionHeadline}>{props.title}</h2>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const classes = require('./SettingsHeadline.module.css');
|
||||
import classes from './SettingsHeadline.module.css';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
|
|
|
@ -2,3 +2,14 @@
|
|||
color: var(--color-primary);
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.AppIconWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
.AppIcon {
|
||||
height: 31px;
|
||||
}
|
||||
|
|
|
@ -1,26 +1,34 @@
|
|||
import classes from './Icon.module.css';
|
||||
|
||||
import { Icon as MDIcon } from '@mdi/react';
|
||||
import { iconParser } from '../../../../utility';
|
||||
import classes from './Icon.module.css';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const Icon = (props: Props): JSX.Element => {
|
||||
export const Icon = ({ icon, color }: Props): JSX.Element => {
|
||||
const MDIcons = require('@mdi/js');
|
||||
let iconPath = MDIcons[props.icon];
|
||||
const mdiIcon = iconParser(icon);
|
||||
let mdiIconPath = MDIcons[mdiIcon];
|
||||
|
||||
if (!iconPath) {
|
||||
console.log(`Icon ${props.icon} not found`);
|
||||
iconPath = MDIcons.mdiCancel;
|
||||
if (!mdiIconPath) {
|
||||
return (
|
||||
<div className={classes.AppIconWrapper}>
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}.png`}
|
||||
className={classes.AppIcon}
|
||||
alt={icon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MDIcon
|
||||
className={classes.Icon}
|
||||
path={mdiIconPath}
|
||||
color={color ? color : 'var(--color-primary)'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MDIcon
|
||||
className={classes.Icon}
|
||||
path={iconPath}
|
||||
color={props.color ? props.color : 'var(--color-primary)'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,345 +13,10 @@ export enum TimeOfDay {
|
|||
night
|
||||
}
|
||||
|
||||
const mapFromJson = require('./WeatherMapping.json')
|
||||
|
||||
export class IconMapping {
|
||||
private conditions: WeatherCondition[] = [
|
||||
{
|
||||
code: 1000,
|
||||
icon: {
|
||||
day: 'clear-day',
|
||||
night: 'clear-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1003,
|
||||
icon: {
|
||||
day: 'partly-cloudy-day',
|
||||
night: 'partly-cloudy-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1006,
|
||||
icon: {
|
||||
day: 'cloudy',
|
||||
night: 'cloudy'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1009,
|
||||
icon: {
|
||||
day: 'cloudy',
|
||||
night: 'cloudy'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1030,
|
||||
icon: {
|
||||
day: 'fog',
|
||||
night: 'fog'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1063,
|
||||
icon: {
|
||||
day: 'rain-day',
|
||||
night: 'rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1066,
|
||||
icon: {
|
||||
day: 'snow-day',
|
||||
night: 'snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1069,
|
||||
icon: {
|
||||
day: 'rain-snow-day',
|
||||
night: 'rain-snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1072,
|
||||
icon: {
|
||||
day: 'sleet',
|
||||
night: 'sleet'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1087,
|
||||
icon: {
|
||||
day: 'thunder-day',
|
||||
night: 'thunder-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1114,
|
||||
icon: {
|
||||
day: 'snow',
|
||||
night: 'snow'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1117,
|
||||
icon: {
|
||||
day: 'snow',
|
||||
night: 'snow'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1135,
|
||||
icon: {
|
||||
day: 'fog',
|
||||
night: 'fog'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1147,
|
||||
icon: {
|
||||
day: 'fog',
|
||||
night: 'fog'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1150,
|
||||
icon: {
|
||||
day: 'rain',
|
||||
night: 'rain'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1153,
|
||||
icon: {
|
||||
day: 'rain',
|
||||
night: 'rain'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1168,
|
||||
icon: {
|
||||
day: 'sleet',
|
||||
night: 'sleet'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1171,
|
||||
icon: {
|
||||
day: 'sleet',
|
||||
night: 'sleet'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1180,
|
||||
icon: {
|
||||
day: 'rain-day',
|
||||
night: 'rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1183,
|
||||
icon: {
|
||||
day: 'rain',
|
||||
night: 'rain'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1186,
|
||||
icon: {
|
||||
day: 'rain-day',
|
||||
night: 'rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1189,
|
||||
icon: {
|
||||
day: 'rain',
|
||||
night: 'rain'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1192,
|
||||
icon: {
|
||||
day: 'rain-day',
|
||||
night: 'rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1195,
|
||||
icon: {
|
||||
day: 'rain',
|
||||
night: 'rain'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1198,
|
||||
icon: {
|
||||
day: 'sleet',
|
||||
night: 'sleet'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1201,
|
||||
icon: {
|
||||
day: 'sleet',
|
||||
night: 'sleet'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1204,
|
||||
icon: {
|
||||
day: 'rain-snow',
|
||||
night: 'rain-snow'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1207,
|
||||
icon: {
|
||||
day: 'rain-snow',
|
||||
night: 'rain-snow'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1210,
|
||||
icon: {
|
||||
day: 'snow-day',
|
||||
night: 'snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1213,
|
||||
icon: {
|
||||
day: 'snow',
|
||||
night: 'snow'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1216,
|
||||
icon: {
|
||||
day: 'snow-day',
|
||||
night: 'snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1219,
|
||||
icon: {
|
||||
day: 'snow',
|
||||
night: 'snow'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1222,
|
||||
icon: {
|
||||
day: 'snow-day',
|
||||
night: 'snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1225,
|
||||
icon: {
|
||||
day: 'snow',
|
||||
night: 'snow'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1237,
|
||||
icon: {
|
||||
day: 'hail',
|
||||
night: 'hail'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1240,
|
||||
icon: {
|
||||
day: 'rain-day',
|
||||
night: 'rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1243,
|
||||
icon: {
|
||||
day: 'rain-day',
|
||||
night: 'rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1246,
|
||||
icon: {
|
||||
day: 'rain-day',
|
||||
night: 'rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1249,
|
||||
icon: {
|
||||
day: 'rain-snow-day',
|
||||
night: 'rain-snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1252,
|
||||
icon: {
|
||||
day: 'rain-snow-day',
|
||||
night: 'rain-snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1255,
|
||||
icon: {
|
||||
day: 'snow-day',
|
||||
night: 'snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1258,
|
||||
icon: {
|
||||
day: 'snow-day',
|
||||
night: 'snow-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1261,
|
||||
icon: {
|
||||
day: 'hail',
|
||||
night: 'hail'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1264,
|
||||
icon: {
|
||||
day: 'hail',
|
||||
night: 'hail'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1273,
|
||||
icon: {
|
||||
day: 'thunder-rain-day',
|
||||
night: 'thunder-rain-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1276,
|
||||
icon: {
|
||||
day: 'thunder-rain',
|
||||
night: 'thunder-rain'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1279,
|
||||
icon: {
|
||||
day: 'thunder-day',
|
||||
night: 'thunder-night'
|
||||
}
|
||||
},
|
||||
{
|
||||
code: 1282,
|
||||
icon: {
|
||||
day: 'thunder',
|
||||
night: 'thunder'
|
||||
}
|
||||
}
|
||||
];
|
||||
private conditions: WeatherCondition[] = mapFromJson.mapping
|
||||
|
||||
mapIcon(weatherStatusCode: number, timeOfDay: TimeOfDay): IconKey {
|
||||
const mapping = this.conditions.find((condition: WeatherCondition) => condition.code === weatherStatusCode);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Skycons } from 'skycons-ts';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { activeThemeAtom } from '../../../../state/theme';
|
||||
import { IconMapping, TimeOfDay } from './IconMapping';
|
||||
|
||||
interface Props {
|
||||
|
@ -10,7 +10,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export const WeatherIcon = (props: Props): JSX.Element => {
|
||||
const { activeTheme } = useSelector((state: State) => state.theme);
|
||||
const activeTheme = useAtomValue(activeThemeAtom);
|
||||
|
||||
const icon = props.isDay
|
||||
? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)
|
||||
|
|
|
@ -1,340 +1,382 @@
|
|||
{
|
||||
"mapping": [
|
||||
{
|
||||
"code": 1000,
|
||||
"code": 800,
|
||||
"icon": {
|
||||
"day": "clear-day",
|
||||
"night": "clear-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1003,
|
||||
"code": 801,
|
||||
"icon": {
|
||||
"day": "partly-cloudy-day",
|
||||
"night": "partly-cloudy-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1006,
|
||||
"code": 802,
|
||||
"icon": {
|
||||
"day": "partly-cloudy-day",
|
||||
"night": "partly-cloudy-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 803,
|
||||
"icon": {
|
||||
"day": "cloudy",
|
||||
"night": "cloudy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1009,
|
||||
"code": 804,
|
||||
"icon": {
|
||||
"day": "cloudy",
|
||||
"night": "cloudy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1030,
|
||||
"code": 701,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1063,
|
||||
"code": 711,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1066,
|
||||
"code": 721,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 731,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 741,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 751,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 761,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 771,
|
||||
"icon": {
|
||||
"day": "wind",
|
||||
"night": "wind"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 781,
|
||||
"icon": {
|
||||
"day": "wind",
|
||||
"night": "wind"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 600,
|
||||
"icon": {
|
||||
"day": "snow-day",
|
||||
"night": "snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1069,
|
||||
"code": 601,
|
||||
"icon": {
|
||||
"day": "snow-day",
|
||||
"night": "snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 602,
|
||||
"icon": {
|
||||
"day": "snow",
|
||||
"night": "snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 611,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 612,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 613,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 615,
|
||||
"icon": {
|
||||
"day": "rain-snow-day",
|
||||
"night": "rain-snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1072,
|
||||
"code": 616,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
"day": "rain-snow-day",
|
||||
"night": "rain-snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1087,
|
||||
"icon": {
|
||||
"day": "thunder-day",
|
||||
"night": "thunder-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1114,
|
||||
"icon": {
|
||||
"day": "snow",
|
||||
"night": "snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1117,
|
||||
"icon": {
|
||||
"day": "snow",
|
||||
"night": "snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1135,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1147,
|
||||
"icon": {
|
||||
"day": "fog",
|
||||
"night": "fog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1150,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1153,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1168,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1171,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1180,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1183,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1186,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1189,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1192,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1195,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1198,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1201,
|
||||
"icon": {
|
||||
"day": "sleet",
|
||||
"night": "sleet"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1204,
|
||||
"code": 620,
|
||||
"icon": {
|
||||
"day": "rain-snow",
|
||||
"night": "rain-snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1207,
|
||||
"code": 621,
|
||||
"icon": {
|
||||
"day": "rain-snow",
|
||||
"night": "rain-snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1210,
|
||||
"code": 622,
|
||||
"icon": {
|
||||
"day": "snow-day",
|
||||
"night": "snow-night"
|
||||
"day": "rain-snow",
|
||||
"night": "rain-snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1213,
|
||||
"icon": {
|
||||
"day": "snow",
|
||||
"night": "snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1216,
|
||||
"icon": {
|
||||
"day": "snow-day",
|
||||
"night": "snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1219,
|
||||
"icon": {
|
||||
"day": "snow",
|
||||
"night": "snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1222,
|
||||
"icon": {
|
||||
"day": "snow-day",
|
||||
"night": "snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1225,
|
||||
"icon": {
|
||||
"day": "snow",
|
||||
"night": "snow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1237,
|
||||
"icon": {
|
||||
"day": "hail",
|
||||
"night": "hail"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1240,
|
||||
"code": 500,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1243,
|
||||
"code": 501,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1246,
|
||||
"code": 502,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1249,
|
||||
"code": 503,
|
||||
"icon": {
|
||||
"day": "rain-snow-day",
|
||||
"night": "rain-snow-night"
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1252,
|
||||
"code": 504,
|
||||
"icon": {
|
||||
"day": "rain-snow-day",
|
||||
"night": "rain-snow-night"
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1255,
|
||||
"icon": {
|
||||
"day": "snow-day",
|
||||
"night": "snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1258,
|
||||
"icon": {
|
||||
"day": "snow-day",
|
||||
"night": "snow-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1261,
|
||||
"code": 511,
|
||||
"icon": {
|
||||
"day": "hail",
|
||||
"night": "hail"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1264,
|
||||
"code": 520,
|
||||
"icon": {
|
||||
"day": "hail",
|
||||
"night": "hail"
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1273,
|
||||
"code": 521,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 522,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 531,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 300,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 301,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 302,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 310,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 311,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 312,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 313,
|
||||
"icon": {
|
||||
"day": "rain-day",
|
||||
"night": "rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 314,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 321,
|
||||
"icon": {
|
||||
"day": "rain",
|
||||
"night": "rain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 200,
|
||||
"icon": {
|
||||
"day": "thunder-rain-day",
|
||||
"night": "thunder-rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1276,
|
||||
"code": 201,
|
||||
"icon": {
|
||||
"day": "thunder-rain",
|
||||
"night": "thunder-rain"
|
||||
"day": "thunder-rain-day",
|
||||
"night": "thunder-rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1279,
|
||||
"code": 202,
|
||||
"icon": {
|
||||
"day": "thunder-rain-day",
|
||||
"night": "thunder-rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 210,
|
||||
"icon": {
|
||||
"day": "thunder-day",
|
||||
"night": "thunder-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 1282,
|
||||
"code": 211,
|
||||
"icon": {
|
||||
"day": "thunder-day",
|
||||
"night": "thunder-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 212,
|
||||
"icon": {
|
||||
"day": "thunder",
|
||||
"night": "thunder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 221,
|
||||
"icon": {
|
||||
"day": "thunder-day",
|
||||
"night": "thunder-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 230,
|
||||
"icon": {
|
||||
"day": "thunder-rain-day",
|
||||
"night": "thunder-rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 231,
|
||||
"icon": {
|
||||
"day": "thunder-rain-day",
|
||||
"night": "thunder-rain-night"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": 232,
|
||||
"icon": {
|
||||
"day": "thunder-rain-day",
|
||||
"night": "thunder-rain-night"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { MouseEvent, ReactNode, useRef } from 'react';
|
||||
|
||||
import classes from './Modal.module.css';
|
||||
|
||||
interface Props {
|
||||
|
@ -9,6 +8,7 @@ interface Props {
|
|||
cb?: Function;
|
||||
}
|
||||
|
||||
// TODO: refactor cb + setIsOpen into onClose()
|
||||
export const Modal = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
import { useClearNotification } from '../../../state/notification';
|
||||
import classes from './Notification.module.css';
|
||||
|
||||
interface Props {
|
||||
|
@ -13,8 +10,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export const Notification = (props: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { clearNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
const clearNotification = useClearNotification();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const elementClasses = [
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
import classes from './Message.module.css';
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -1,27 +1,17 @@
|
|||
import { useState, useEffect, Fragment } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { Weather, ApiResponse } from '../../../interfaces';
|
||||
|
||||
// CSS
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { ApiResponse, Weather } from '../../../interfaces';
|
||||
import { configAtom, configLoadingAtom } from '../../../state/config';
|
||||
import { weatherTemplate } from '../../../utility/templateObjects/weatherTemplate';
|
||||
import { WeatherIcon } from '../../UI';
|
||||
import classes from './WeatherWidget.module.css';
|
||||
|
||||
// UI
|
||||
import { WeatherIcon } from '../../UI';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { weatherTemplate } from '../../../utility/templateObjects/weatherTemplate';
|
||||
|
||||
export const WeatherWidget = (): JSX.Element => {
|
||||
const { loading: configLoading, config } = useSelector(
|
||||
(state: State) => state.config
|
||||
);
|
||||
const configLoading = useAtomValue(configLoadingAtom);
|
||||
const config = useAtomValue(configAtom);
|
||||
|
||||
const [weather, setWeather] = useState<Weather>(weatherTemplate);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Initial request to get data
|
||||
useEffect(() => {
|
||||
|
@ -32,7 +22,6 @@ export const WeatherWidget = (): JSX.Element => {
|
|||
if (weatherData) {
|
||||
setWeather(weatherData);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
}, []);
|
||||
|
@ -59,7 +48,7 @@ export const WeatherWidget = (): JSX.Element => {
|
|||
<div className={classes.WeatherWidget}>
|
||||
{configLoading ||
|
||||
(config.WEATHER_API_KEY && weather.id > 0 && (
|
||||
<Fragment>
|
||||
<>
|
||||
<div className={classes.WeatherIcon}>
|
||||
<WeatherIcon
|
||||
weatherStatusCode={weather.conditionCode}
|
||||
|
@ -75,9 +64,12 @@ export const WeatherWidget = (): JSX.Element => {
|
|||
)}
|
||||
|
||||
{/* ADDITIONAL DATA */}
|
||||
<span>{weather[config.weatherData]}%</span>
|
||||
<span>
|
||||
{weather.conditionText} ·
|
||||
{weather[config.weatherData]}%
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -2,16 +2,11 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store/store';
|
||||
|
||||
import { App } from './App';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
|
|
@ -16,8 +16,10 @@ export interface Config {
|
|||
hideApps: boolean;
|
||||
hideCategories: boolean;
|
||||
hideSearch: boolean;
|
||||
hideSearchProvider: boolean;
|
||||
defaultSearchProvider: string;
|
||||
secondarySearchProvider: string;
|
||||
autoClearSearch: boolean;
|
||||
dockerApps: boolean;
|
||||
dockerHost: string;
|
||||
kubernetesApps: boolean;
|
||||
|
|
|
@ -31,7 +31,9 @@ export interface UISettingsForm {
|
|||
showTime: boolean;
|
||||
hideDate: boolean;
|
||||
hideSearch: boolean;
|
||||
hideSearchProvider: boolean;
|
||||
disableAutofocus: boolean;
|
||||
autoClearSearch: boolean;
|
||||
}
|
||||
|
||||
export interface DockerSettingsForm {
|
||||
|
|
155
client/src/state/app.ts
Normal file
155
client/src/state/app.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import axios from 'axios';
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { ApiResponse, App, Config, NewApp } from '../interfaces';
|
||||
import { applyAuth, insertAt, sortData } from '../utility';
|
||||
import { configAtom } from './config';
|
||||
import { successMessage, useCreateNotification } from './notification';
|
||||
|
||||
export const appsLoadingAtom = atom(true);
|
||||
export const appsAtom = atom<App[]>([]);
|
||||
export const appInUpdateAtom = atom<App | null>(null);
|
||||
|
||||
export const useFetchApps = () => {
|
||||
const setAppsLoading = useSetAtom(appsLoadingAtom);
|
||||
const setApps = useSetAtom(appsAtom);
|
||||
|
||||
return async () => {
|
||||
setAppsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<App[]>>('/api/apps', {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
|
||||
setApps(res.data.data);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
setAppsLoading(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const usePinApp = () => {
|
||||
const apps = useAtomValue(appsAtom);
|
||||
const setSortedApps = useSetSortedApps();
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
return async (app: App) => {
|
||||
try {
|
||||
const { id, isPinned, name } = app;
|
||||
const res = await axios.put<ApiResponse<App>>(
|
||||
`/api/apps/${id}`,
|
||||
{ isPinned: !isPinned },
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
const status = isPinned
|
||||
? 'unpinned from Homescreen'
|
||||
: 'pinned to Homescreen';
|
||||
|
||||
createNotification(successMessage(`App ${name} ${status}`));
|
||||
const appIdx = apps.findIndex(({ id }) => id === res.data.data.id);
|
||||
setSortedApps(insertAt(apps, appIdx, res.data.data));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useAddApp = () => {
|
||||
const apps = useAtomValue(appsAtom);
|
||||
const setSortApps = useSetSortedApps();
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
return async (formData: NewApp | FormData) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<App>>('/api/apps', formData, {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
|
||||
createNotification(successMessage('App added'));
|
||||
|
||||
setSortApps([...apps, res.data.data]);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeleteApp = () => {
|
||||
const setApps = useSetAtom(appsAtom);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
return async (deleteId: App['id']) => {
|
||||
try {
|
||||
await axios.delete<ApiResponse<{}>>(`/api/apps/${deleteId}`, {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
|
||||
createNotification(successMessage('App deleted'));
|
||||
setApps((prev) => prev.filter(({ id }) => id !== deleteId));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdateApp = () => {
|
||||
const apps = useAtomValue(appsAtom);
|
||||
const setSortedApps = useSetSortedApps();
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
return async (updateId: App['id'], formData: NewApp | FormData) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<App>>(
|
||||
`/api/apps/${updateId}`,
|
||||
formData,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
createNotification(successMessage('App updated'));
|
||||
const appIdx = apps.findIndex(({ id }) => id === res.data.data.id);
|
||||
setSortedApps(insertAt(apps, appIdx, res.data.data));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
export const useReorderApps = () => {
|
||||
const setApps = useSetAtom(appsAtom);
|
||||
return async (apps: App[]) => {
|
||||
interface ReorderQuery {
|
||||
apps: {
|
||||
id: App['id'];
|
||||
orderId: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
try {
|
||||
const updateQuery: ReorderQuery = {
|
||||
apps: apps.map((app, index) => ({
|
||||
id: app.id,
|
||||
orderId: index + 1,
|
||||
})),
|
||||
};
|
||||
|
||||
await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery, {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
|
||||
setApps(apps);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useSetSortedApps = () => {
|
||||
const { useOrdering } = useAtomValue(configAtom);
|
||||
const [apps, setApps] = useAtom(appsAtom);
|
||||
|
||||
return (localApps?: App[]) => {
|
||||
setApps(sortData<App>(localApps || apps, useOrdering));
|
||||
};
|
||||
};
|
99
client/src/state/auth.ts
Normal file
99
client/src/state/auth.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import axios, { AxiosError } from 'axios';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { ApiResponse } from '../interfaces';
|
||||
import { useFetchApps } from './app';
|
||||
import { useFetchCategories } from './bookmark';
|
||||
import { errorMessage, useCreateNotification } from './notification';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
const loggedOutState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
};
|
||||
|
||||
export const authAtom = atom<AuthState>(loggedOutState);
|
||||
|
||||
export const useLogin = () => {
|
||||
const setAuth = useSetAtom(authAtom);
|
||||
const fetchApps = useFetchApps();
|
||||
const fetchCategories = useFetchCategories();
|
||||
const authError = useAuthError();
|
||||
|
||||
return async (formData: { password: string; duration: string }) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<{ token: string }>>(
|
||||
'/api/auth',
|
||||
formData
|
||||
);
|
||||
const token = res.data.data.token;
|
||||
|
||||
localStorage.setItem('token', token);
|
||||
setAuth({ token, isAuthenticated: true });
|
||||
|
||||
fetchApps();
|
||||
fetchCategories();
|
||||
} catch (err) {
|
||||
authError(err, true);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useLogout = () => {
|
||||
const setAuth = useSetAtom(authAtom);
|
||||
const fetchApps = useFetchApps();
|
||||
const fetchCategories = useFetchCategories();
|
||||
|
||||
return () => {
|
||||
localStorage.removeItem('token');
|
||||
setAuth(loggedOutState);
|
||||
|
||||
fetchApps();
|
||||
fetchCategories();
|
||||
};
|
||||
};
|
||||
|
||||
export const useAutoLogin = () => {
|
||||
const setAuth = useSetAtom(authAtom);
|
||||
const fetchApps = useFetchApps();
|
||||
const fetchCategories = useFetchCategories();
|
||||
const authError = useAuthError();
|
||||
|
||||
return async () => {
|
||||
const token: string = localStorage.token;
|
||||
|
||||
try {
|
||||
await axios.post<ApiResponse<{ token: { isValid: boolean } }>>(
|
||||
'/api/auth/validate',
|
||||
{ token }
|
||||
);
|
||||
|
||||
setAuth({ token, isAuthenticated: true });
|
||||
|
||||
fetchApps();
|
||||
fetchCategories();
|
||||
} catch (err) {
|
||||
authError(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useAuthError = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const fetchApps = useFetchApps();
|
||||
|
||||
return (error: unknown, showNotification: boolean = false) => {
|
||||
const apiError = error as AxiosError;
|
||||
|
||||
if (showNotification) {
|
||||
createNotification(
|
||||
errorMessage(apiError.response?.data.error ?? 'Authenticaton error')
|
||||
);
|
||||
}
|
||||
|
||||
fetchApps();
|
||||
};
|
||||
};
|
378
client/src/state/bookmark.ts
Normal file
378
client/src/state/bookmark.ts
Normal file
|
@ -0,0 +1,378 @@
|
|||
import axios from 'axios';
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
ApiResponse,
|
||||
Bookmark,
|
||||
Category,
|
||||
NewBookmark,
|
||||
NewCategory,
|
||||
} from '../interfaces';
|
||||
import { applyAuth, insertAt, sortData } from '../utility';
|
||||
import { configAtom } from './config';
|
||||
import { successMessage, useCreateNotification } from './notification';
|
||||
|
||||
export const bookmarksLoadingAtom = atom(true);
|
||||
export const categoriesAtom = atom<Category[]>([]);
|
||||
export const categoryInEditAtom = atom<Category | null>(null);
|
||||
export const bookmarkInEditAtom = atom<Bookmark | null>(null);
|
||||
|
||||
export const useFetchCategories = () => {
|
||||
const setLoading = useSetAtom(bookmarksLoadingAtom);
|
||||
const setCategories = useSetAtom(categoriesAtom);
|
||||
|
||||
return async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<Category[]>>('/api/categories', {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
setCategories(res.data.data);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useAddCategory = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const setSortedCategories = useSetSortedCategories();
|
||||
|
||||
return async (formData: NewCategory) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<Category>>(
|
||||
'/api/categories',
|
||||
formData,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
createNotification(successMessage(`Category ${formData.name} created`));
|
||||
const category = res.data.data;
|
||||
|
||||
const newCategories = [...categories, { ...category, bookmarks: [] }];
|
||||
setSortedCategories(newCategories);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const usePinCategory = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const [categories, setCategories] = useAtom(categoriesAtom);
|
||||
|
||||
return async ({ id, isPinned, name }: Category) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Category>>(
|
||||
`/api/categories/${id}`,
|
||||
{ isPinned: !isPinned },
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
const status = isPinned
|
||||
? 'unpinned from Homescreen'
|
||||
: 'pinned to Homescreen';
|
||||
|
||||
createNotification(successMessage(`Category ${name} ${status}`));
|
||||
|
||||
const category = res.data.data;
|
||||
const categoryIdx = categories.findIndex(({ id }) => id === category.id);
|
||||
|
||||
setCategories(
|
||||
insertAt(categories, categoryIdx, {
|
||||
...category,
|
||||
bookmarks: [...categories[categoryIdx].bookmarks],
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeleteCategory = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const setCategories = useSetAtom(categoriesAtom);
|
||||
|
||||
return async (id: number) => {
|
||||
try {
|
||||
await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`, {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
|
||||
createNotification(successMessage('Category deleted'));
|
||||
setCategories((prev) => prev.filter((category) => category.id !== id));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdateCategory = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const setSortCategories = useSetSortedCategories();
|
||||
|
||||
return async (id: number, formData: NewCategory) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Category>>(
|
||||
`/api/categories/${id}`,
|
||||
formData,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
createNotification(successMessage(`Category ${formData.name} updated`));
|
||||
|
||||
const category = res.data.data;
|
||||
|
||||
const categoryIdx = categories.findIndex(({ id }) => id === category.id);
|
||||
const newCategories = insertAt(categories, categoryIdx, {
|
||||
...category,
|
||||
bookmarks: [...categories[categoryIdx].bookmarks],
|
||||
});
|
||||
setSortCategories(newCategories);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useAddBookmark = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const setSortedBookmarks = useSetSortedBookmarks();
|
||||
const setCategoryInEdit = useSetAtom(categoryInEditAtom);
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
|
||||
return async (formData: NewBookmark | FormData) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<Bookmark>>(
|
||||
'/api/bookmarks',
|
||||
formData,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
createNotification(successMessage(`Bookmark created`));
|
||||
|
||||
const newBookmark = res.data.data;
|
||||
|
||||
const categoryIdx = categories.findIndex(
|
||||
({ id }) => id === newBookmark.categoryId
|
||||
);
|
||||
|
||||
const targetCategory = {
|
||||
...categories[categoryIdx],
|
||||
bookmarks: [...categories[categoryIdx].bookmarks, newBookmark],
|
||||
};
|
||||
|
||||
const newCategories = insertAt(categories, categoryIdx, targetCategory);
|
||||
setSortedBookmarks(res.data.data.categoryId, newCategories);
|
||||
setCategoryInEdit(targetCategory);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeleteBookmark = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const setSortedBookmarks = useSetSortedBookmarks();
|
||||
const setCategoryInEdit = useSetAtom(categoryInEditAtom);
|
||||
|
||||
return async (bookmarkId: number, categoryId: number) => {
|
||||
try {
|
||||
await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`, {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
|
||||
createNotification(successMessage('Bookmark deleted'));
|
||||
|
||||
const categoryIdx = categories.findIndex(({ id }) => id === categoryId);
|
||||
const targetCategory = {
|
||||
...categories[categoryIdx],
|
||||
bookmarks: categories[categoryIdx].bookmarks.filter(
|
||||
(bookmark) => bookmark.id !== bookmarkId
|
||||
),
|
||||
};
|
||||
|
||||
const newCategories = insertAt(categories, categoryIdx, targetCategory);
|
||||
setSortedBookmarks(categoryId, newCategories);
|
||||
setCategoryInEdit(targetCategory);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdateBookmark = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const deleteBookmark = useDeleteBookmark();
|
||||
const addBookmark = useAddBookmark();
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
const setCategoryInEdit = useSetAtom(categoryInEditAtom);
|
||||
const setSortedBookmarks = useSetSortedBookmarks();
|
||||
|
||||
return async (
|
||||
bookmarkId: number,
|
||||
formData: NewBookmark | FormData,
|
||||
category: {
|
||||
prev: number;
|
||||
curr: number;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Bookmark>>(
|
||||
`/api/bookmarks/${bookmarkId}`,
|
||||
formData,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
const newBookmark = res.data.data;
|
||||
|
||||
createNotification(successMessage('Bookmark updated'));
|
||||
|
||||
// Check if category was changed
|
||||
const categoryWasChanged = category.curr !== category.prev;
|
||||
|
||||
if (categoryWasChanged) {
|
||||
// Delete bookmark from old category
|
||||
deleteBookmark(bookmarkId, category.prev);
|
||||
|
||||
// Add bookmark to the new category
|
||||
addBookmark(newBookmark);
|
||||
} else {
|
||||
// Else replace in current category
|
||||
const categoryIdx = categories.findIndex(
|
||||
({ id }) => id === newBookmark.categoryId
|
||||
);
|
||||
|
||||
const prevBookmarks = categories[categoryIdx].bookmarks;
|
||||
|
||||
const bookmarkIdx = prevBookmarks.findIndex(
|
||||
({ id }) => id === newBookmark.id
|
||||
);
|
||||
|
||||
const targetCategory = {
|
||||
...categories[categoryIdx],
|
||||
bookmarks: insertAt(prevBookmarks, bookmarkIdx, newBookmark),
|
||||
};
|
||||
|
||||
setCategoryInEdit(targetCategory);
|
||||
setSortedBookmarks(
|
||||
res.data.data.categoryId,
|
||||
insertAt(categories, categoryIdx, targetCategory)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useSetSortedCategories = () => {
|
||||
const { useOrdering } = useAtomValue(configAtom);
|
||||
const setCategories = useSetAtom(categoriesAtom);
|
||||
const categories = useAtomValue(categoriesAtom);
|
||||
|
||||
return (localCategories?: Category[]) => {
|
||||
setCategories(
|
||||
sortData<Category>(localCategories || categories, useOrdering)
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const useReorderCategories = () => {
|
||||
const setCategories = useSetAtom(categoriesAtom);
|
||||
|
||||
return async (categories: Category[]) => {
|
||||
interface ReorderQuery {
|
||||
categories: {
|
||||
id: number;
|
||||
orderId: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
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,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
setCategories(categories);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useReorderBookmarks = () => {
|
||||
const [categories, setCategories] = useAtom(categoriesAtom);
|
||||
|
||||
return async (bookmarks: Bookmark[], categoryId: number) => {
|
||||
interface ReorderQuery {
|
||||
bookmarks: {
|
||||
id: number;
|
||||
orderId: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
try {
|
||||
const updateQuery: ReorderQuery = { bookmarks: [] };
|
||||
|
||||
bookmarks.forEach((bookmark, index) =>
|
||||
updateQuery.bookmarks.push({
|
||||
id: bookmark.id,
|
||||
orderId: index + 1,
|
||||
})
|
||||
);
|
||||
|
||||
await axios.put<ApiResponse<{}>>(
|
||||
'/api/bookmarks/0/reorder',
|
||||
updateQuery,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
const categoryIdx = categories.findIndex(({ id }) => id === categoryId);
|
||||
const newCategories = insertAt(categories, categoryIdx, {
|
||||
...categories[categoryIdx],
|
||||
bookmarks,
|
||||
});
|
||||
|
||||
setCategories(newCategories);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useSetSortedBookmarks = () => {
|
||||
const { useOrdering } = useAtomValue(configAtom);
|
||||
const [categories, setCategories] = useAtom(categoriesAtom);
|
||||
|
||||
return (categoryId: number, localCategories?: Category[]) => {
|
||||
const targetCategories = localCategories || categories;
|
||||
|
||||
const categoryIdx = targetCategories.findIndex(
|
||||
({ id }) => id === categoryId
|
||||
);
|
||||
|
||||
const category = targetCategories[categoryIdx];
|
||||
const sortedBookmarks = sortData<Bookmark>(category.bookmarks, useOrdering);
|
||||
|
||||
const newCategories = insertAt(targetCategories, categoryIdx, {
|
||||
...category,
|
||||
bookmarks: sortedBookmarks,
|
||||
});
|
||||
|
||||
setCategories(newCategories);
|
||||
};
|
||||
};
|
69
client/src/state/config.ts
Normal file
69
client/src/state/config.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import axios from 'axios';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { ApiResponse, Config } from '../interfaces';
|
||||
import { ConfigFormData } from '../types';
|
||||
import { applyAuth, configTemplate, storeUIConfig } from '../utility';
|
||||
import { successMessage, useCreateNotification } from './notification';
|
||||
|
||||
export const configLoadingAtom = atom(true);
|
||||
export const configAtom = atom<Config>(configTemplate);
|
||||
|
||||
const persistedConfigKeys: (keyof Config)[] = [
|
||||
'useAmericanDate',
|
||||
'greetingsSchema',
|
||||
'daySchema',
|
||||
'monthSchema',
|
||||
'showTime',
|
||||
'hideDate',
|
||||
];
|
||||
|
||||
const useReplaceConfig = () => {
|
||||
const setConfig = useSetAtom(configAtom);
|
||||
|
||||
return (config: Config) => {
|
||||
setConfig(config);
|
||||
persistedConfigKeys.forEach((key) => storeUIConfig(key, config));
|
||||
document.title = config.customTitle;
|
||||
};
|
||||
};
|
||||
|
||||
export const useFetchConfig = () => {
|
||||
const setConfigLoading = useSetAtom(configLoadingAtom);
|
||||
const setConfig = useSetAtom(configAtom);
|
||||
|
||||
return async () => {
|
||||
setConfigLoading(true);
|
||||
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<Config>>('/api/config');
|
||||
const config = res.data.data;
|
||||
|
||||
setConfig(config);
|
||||
persistedConfigKeys.forEach((key) => storeUIConfig(key, config));
|
||||
document.title = config.customTitle;
|
||||
setConfigLoading(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdateConfig = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const replaceConfig = useReplaceConfig();
|
||||
|
||||
return async (formData: ConfigFormData) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Config>>(
|
||||
'/api/config',
|
||||
formData,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
const config = res.data.data;
|
||||
replaceConfig(config);
|
||||
createNotification(successMessage('Settings updated'));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
49
client/src/state/notification.ts
Normal file
49
client/src/state/notification.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { atom, useSetAtom } from 'jotai';
|
||||
import { NewNotification, Notification } from '../interfaces';
|
||||
|
||||
export interface NotificationState {
|
||||
notifications: Notification[];
|
||||
idCounter: number;
|
||||
}
|
||||
|
||||
export const notificationsAtom = atom<NotificationState>({
|
||||
notifications: [],
|
||||
idCounter: 0,
|
||||
});
|
||||
|
||||
export const successMessage = (message: string): NewNotification => ({
|
||||
title: 'Success',
|
||||
message,
|
||||
});
|
||||
|
||||
export const errorMessage = (message: string): NewNotification => ({
|
||||
title: 'Error',
|
||||
message,
|
||||
});
|
||||
|
||||
export const infoMessage = (message: string): NewNotification => ({
|
||||
title: 'Info',
|
||||
message,
|
||||
});
|
||||
|
||||
export const useCreateNotification = () => {
|
||||
const setNotifications = useSetAtom(notificationsAtom);
|
||||
|
||||
return (newNotification: NewNotification) => {
|
||||
setNotifications(({ notifications, idCounter }) => ({
|
||||
notifications: [...notifications, { ...newNotification, id: idCounter }],
|
||||
idCounter: idCounter + 1,
|
||||
}));
|
||||
};
|
||||
};
|
||||
|
||||
export const useClearNotification = () => {
|
||||
const setNotifications = useSetAtom(notificationsAtom);
|
||||
|
||||
return (removeId: Notification['id']) => {
|
||||
setNotifications((prev) => ({
|
||||
...prev,
|
||||
notifications: prev.notifications.filter(({ id }) => id !== removeId),
|
||||
}));
|
||||
};
|
||||
};
|
81
client/src/state/queries.ts
Normal file
81
client/src/state/queries.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import axios, { AxiosError } from 'axios';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { ApiResponse, Query } from '../interfaces';
|
||||
import { applyAuth } from '../utility';
|
||||
import { errorMessage, useCreateNotification } from './notification';
|
||||
|
||||
export const customQueriesAtom = atom<Query[]>([]);
|
||||
|
||||
export const useFetchQueries = () => {
|
||||
const setQueries = useSetAtom(customQueriesAtom);
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
|
||||
setQueries(res.data.data);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useAddQuery = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const setQueries = useSetAtom(customQueriesAtom);
|
||||
|
||||
return async (query: Query) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<Query>>('/api/queries', query, {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
setQueries((prev) => [...prev, res.data.data]);
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ error: string }>;
|
||||
createNotification(
|
||||
errorMessage(error.response?.data.error ?? 'Unable to add query')
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeleteQuery = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const setQueries = useSetAtom(customQueriesAtom);
|
||||
|
||||
return async (prefix: string) => {
|
||||
try {
|
||||
const res = await axios.delete<ApiResponse<Query[]>>(
|
||||
`/api/queries/${prefix}`,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
setQueries(res.data.data);
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ error: string }>;
|
||||
createNotification(
|
||||
errorMessage(error.response?.data.error ?? 'Unable to delete query')
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdateQuery = () => {
|
||||
const createNotification = useCreateNotification();
|
||||
const setQueries = useSetAtom(customQueriesAtom);
|
||||
|
||||
return async (query: Query, oldPrefix: string) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Query[]>>(
|
||||
`/api/queries/${oldPrefix}`,
|
||||
query,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
setQueries(res.data.data);
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ error: string }>;
|
||||
createNotification(
|
||||
errorMessage(error.response?.data.error ?? 'Unable to update query')
|
||||
);
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
127
client/src/state/theme.ts
Normal file
127
client/src/state/theme.ts
Normal file
|
@ -0,0 +1,127 @@
|
|||
import axios, { AxiosError } from 'axios';
|
||||
import { atom, useSetAtom } from 'jotai';
|
||||
import { ApiResponse, Theme, ThemeColors } from '../interfaces';
|
||||
import {
|
||||
applyAuth,
|
||||
arrayPartition,
|
||||
parsePABToTheme,
|
||||
parseThemeToPAB,
|
||||
} from '../utility';
|
||||
import {
|
||||
errorMessage,
|
||||
successMessage,
|
||||
useCreateNotification,
|
||||
} from './notification';
|
||||
|
||||
const savedTheme = localStorage.theme
|
||||
? parsePABToTheme(localStorage.theme)
|
||||
: parsePABToTheme('#effbff;#6ee2ff;#242b33');
|
||||
|
||||
export const activeThemeAtom = atom<Theme>({
|
||||
name: 'main',
|
||||
isCustom: false,
|
||||
colors: {
|
||||
...savedTheme,
|
||||
},
|
||||
});
|
||||
|
||||
export const themesAtom = atom<Theme[]>([]);
|
||||
export const userThemesAtom = atom<Theme[]>([]);
|
||||
export const themeInEditAtom = atom<Theme | null>(null);
|
||||
|
||||
export const useFetchThemes = () => {
|
||||
const setThemes = useSetAtom(themesAtom);
|
||||
const setUserThemes = useSetAtom(userThemesAtom);
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const res = await axios.get<ApiResponse<Theme[]>>('/api/themes');
|
||||
const [themes, userThemes] = arrayPartition<Theme>(
|
||||
res.data.data,
|
||||
(e) => !e.isCustom
|
||||
);
|
||||
|
||||
setThemes(themes);
|
||||
setUserThemes(userThemes);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useSetTheme = () => {
|
||||
const setTheme = useSetAtom(activeThemeAtom);
|
||||
|
||||
return (colors: ThemeColors, remember: boolean = true) => {
|
||||
if (remember) {
|
||||
localStorage.setItem('theme', parseThemeToPAB(colors));
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
document.body.style.setProperty(`--color-${key}`, value);
|
||||
}
|
||||
|
||||
setTheme((prev) => ({ ...prev, colors }));
|
||||
};
|
||||
};
|
||||
|
||||
export const useAddTheme = () => {
|
||||
const setUserThemes = useSetAtom(userThemesAtom);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
return async (theme: Theme) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<Theme>>('/api/themes', theme, {
|
||||
headers: applyAuth(),
|
||||
});
|
||||
|
||||
setUserThemes((prev) => [...prev, res.data.data]);
|
||||
createNotification(successMessage('Theme added'));
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ error: string }>;
|
||||
createNotification(
|
||||
errorMessage(error.response?.data.error ?? 'Unable to add theme')
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useUpdateTheme = () => {
|
||||
const setUserThemes = useSetAtom(userThemesAtom);
|
||||
|
||||
return async (theme: Theme, originalName: string) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Theme[]>>(
|
||||
`/api/themes/${originalName}`,
|
||||
theme,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
setUserThemes(res.data.data);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeleteTheme = () => {
|
||||
const setUserThemes = useSetAtom(userThemesAtom);
|
||||
const createNotification = useCreateNotification();
|
||||
|
||||
return async (name: string) => {
|
||||
try {
|
||||
const res = await axios.delete<ApiResponse<Theme[]>>(
|
||||
`/api/themes/${name}`,
|
||||
{ headers: applyAuth() }
|
||||
);
|
||||
|
||||
setUserThemes(res.data.data);
|
||||
createNotification(successMessage('Theme deleted'));
|
||||
} catch (err) {
|
||||
const error = err as AxiosError<{ error: string }>;
|
||||
createNotification(
|
||||
errorMessage(error.response?.data.error ?? 'Unable to delete theme')
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
17
client/src/utility/array.ts
Normal file
17
client/src/utility/array.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
export const arrayPartition = <T>(
|
||||
arr: T[],
|
||||
isValid: (e: T) => boolean
|
||||
): T[][] => {
|
||||
let pass: T[] = [];
|
||||
let fail: T[] = [];
|
||||
|
||||
arr.forEach((e) => (isValid(e) ? pass : fail).push(e));
|
||||
|
||||
return [pass, fail];
|
||||
};
|
||||
|
||||
export const insertAt = <T>(arr: T[], index: number, element: T): T[] => [
|
||||
...arr.slice(0, index),
|
||||
element,
|
||||
...arr.slice(index + 1),
|
||||
];
|
|
@ -1,34 +1,32 @@
|
|||
import axios from 'axios';
|
||||
import { store } from '../store/store';
|
||||
import { createNotification } from '../store/action-creators';
|
||||
import { useCreateNotification } from '../state/notification';
|
||||
|
||||
export const checkVersion = async (isForced: boolean = false) => {
|
||||
try {
|
||||
const res = await axios.get<string>(
|
||||
'https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env'
|
||||
);
|
||||
export const useCheckVersion = (isForced: boolean = false) => {
|
||||
const createNotification = useCreateNotification();
|
||||
return async () => {
|
||||
try {
|
||||
const res = await axios.get<string>(
|
||||
'https://raw.githubusercontent.com/GeorgeSG/flame/master/client/.env'
|
||||
);
|
||||
|
||||
const githubVersion = res.data
|
||||
.split('\n')
|
||||
.map((pair) => pair.split('='))[0][1];
|
||||
const githubVersion = res.data
|
||||
.split('\n')
|
||||
.map((pair) => pair.split('='))[0][1];
|
||||
|
||||
if (githubVersion !== process.env.REACT_APP_VERSION) {
|
||||
store.dispatch<any>(
|
||||
if (githubVersion !== process.env.REACT_APP_VERSION) {
|
||||
createNotification({
|
||||
title: 'Info',
|
||||
message: 'New version is available!',
|
||||
url: 'https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md',
|
||||
})
|
||||
);
|
||||
} else if (isForced) {
|
||||
store.dispatch<any>(
|
||||
url: 'https://github.com/GeorgeSG/flame/blob/master/CHANGELOG.md',
|
||||
});
|
||||
} else if (isForced) {
|
||||
createNotification({
|
||||
title: 'Info',
|
||||
message: 'You are using the latest version!',
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -4,6 +4,10 @@
|
|||
* @returns Parsed icon name to be used with mdi/js, e.g mdiAlertBoxOutline
|
||||
*/
|
||||
export const iconParser = (mdiName: string): string => {
|
||||
if (mdiName.startsWith('mdi')) {
|
||||
return mdiName;
|
||||
}
|
||||
|
||||
let parsedName = mdiName
|
||||
.split('-')
|
||||
.map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
|
||||
|
@ -11,4 +15,4 @@ export const iconParser = (mdiName: string): string => {
|
|||
parsedName = `mdi${parsedName}`;
|
||||
|
||||
return parsedName;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -13,4 +13,4 @@ export * from './decodeToken';
|
|||
export * from './applyAuth';
|
||||
export * from './escapeRegex';
|
||||
export * from './parseTheme';
|
||||
export * from './arrayPartition';
|
||||
export * from './array';
|
||||
|
|
|
@ -1,68 +1,72 @@
|
|||
import { queries } from './searchQueries.json';
|
||||
import { SearchResult } from '../interfaces';
|
||||
import { store } from '../store/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { isUrlOrIp } from '.';
|
||||
import { SearchResult } from '../interfaces';
|
||||
import { configAtom } from '../state/config';
|
||||
import { customQueriesAtom } from '../state/queries';
|
||||
import { queries } from './searchQueries.json';
|
||||
|
||||
export const searchParser = (searchQuery: string): SearchResult => {
|
||||
const result: SearchResult = {
|
||||
isLocal: false,
|
||||
isURL: false,
|
||||
sameTab: false,
|
||||
encodedURL: '',
|
||||
primarySearch: {
|
||||
name: '',
|
||||
prefix: '',
|
||||
template: '',
|
||||
},
|
||||
secondarySearch: {
|
||||
name: '',
|
||||
prefix: '',
|
||||
template: '',
|
||||
},
|
||||
rawQuery: searchQuery,
|
||||
};
|
||||
export const useSearchParser = () => {
|
||||
const customQueries = useAtomValue(customQueriesAtom);
|
||||
const config = useAtomValue(configAtom);
|
||||
|
||||
const { customQueries, config } = store.getState().config;
|
||||
return (searchQuery: string): SearchResult => {
|
||||
const result: SearchResult = {
|
||||
isLocal: false,
|
||||
isURL: false,
|
||||
sameTab: false,
|
||||
encodedURL: '',
|
||||
primarySearch: {
|
||||
name: '',
|
||||
prefix: '',
|
||||
template: '',
|
||||
},
|
||||
secondarySearch: {
|
||||
name: '',
|
||||
prefix: '',
|
||||
template: '',
|
||||
},
|
||||
rawQuery: searchQuery,
|
||||
};
|
||||
|
||||
// Check if url or ip was passed
|
||||
result.isURL = isUrlOrIp(searchQuery);
|
||||
// Check if url or ip was passed
|
||||
result.isURL = isUrlOrIp(searchQuery);
|
||||
|
||||
// Match prefix and query
|
||||
const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i);
|
||||
// Match prefix and query
|
||||
const splitQuery = searchQuery.match(/^\/([a-z]+)(.+)$/i);
|
||||
|
||||
// Extract prefix
|
||||
const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
|
||||
// Extract prefix
|
||||
const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
|
||||
|
||||
// Encode url
|
||||
const encodedURL = splitQuery
|
||||
? encodeURIComponent(splitQuery[2])
|
||||
: encodeURIComponent(searchQuery);
|
||||
// Encode url
|
||||
const encodedURL = splitQuery
|
||||
? encodeURIComponent(splitQuery[2])
|
||||
: encodeURIComponent(searchQuery);
|
||||
|
||||
// Find primary search engine template
|
||||
const findProvider = (prefix: string) => {
|
||||
return [...queries, ...customQueries].find((q) => q.prefix === prefix);
|
||||
};
|
||||
// Find primary search engine template
|
||||
const findProvider = (prefix: string) => {
|
||||
return [...queries, ...customQueries].find((q) => q.prefix === prefix);
|
||||
};
|
||||
|
||||
const primarySearch = findProvider(prefix);
|
||||
const secondarySearch = findProvider(config.secondarySearchProvider);
|
||||
const primarySearch = findProvider(prefix);
|
||||
const secondarySearch = findProvider(config.secondarySearchProvider);
|
||||
|
||||
// If search providers were found
|
||||
if (primarySearch) {
|
||||
result.primarySearch = primarySearch;
|
||||
result.encodedURL = encodedURL;
|
||||
// If search providers were found
|
||||
if (primarySearch) {
|
||||
result.primarySearch = primarySearch;
|
||||
result.encodedURL = encodedURL;
|
||||
|
||||
if (prefix === 'l') {
|
||||
result.isLocal = true;
|
||||
} else {
|
||||
if (prefix === 'l') {
|
||||
result.isLocal = true;
|
||||
}
|
||||
result.sameTab = config.searchSameTab;
|
||||
}
|
||||
|
||||
if (secondarySearch) {
|
||||
result.secondarySearch = secondarySearch;
|
||||
if (secondarySearch) {
|
||||
result.secondarySearch = secondarySearch;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -16,8 +16,10 @@ export const configTemplate: Config = {
|
|||
hideApps: false,
|
||||
hideCategories: false,
|
||||
hideSearch: false,
|
||||
hideSearchProvider: false,
|
||||
defaultSearchProvider: 'l',
|
||||
secondarySearchProvider: 'd',
|
||||
autoClearSearch: false,
|
||||
dockerApps: false,
|
||||
dockerHost: 'localhost',
|
||||
kubernetesApps: false,
|
||||
|
|
|
@ -19,7 +19,9 @@ export const uiSettingsTemplate: UISettingsForm = {
|
|||
showTime: false,
|
||||
hideDate: false,
|
||||
hideSearch: false,
|
||||
hideSearchProvider: false,
|
||||
disableAutofocus: false,
|
||||
autoClearSearch: false,
|
||||
};
|
||||
|
||||
export const weatherSettingsTemplate: WeatherForm = {
|
||||
|
|
|
@ -11,62 +11,20 @@ const useDocker = async (apps) => {
|
|||
dockerHost: host,
|
||||
} = await loadConfig();
|
||||
|
||||
const dockerApps = [];
|
||||
|
||||
let containers = null;
|
||||
let containers_swarm = null;
|
||||
|
||||
function addApp(dockerApps, labels){
|
||||
// add each container as flame formatted app
|
||||
if (
|
||||
'flame.name' in labels &&
|
||||
'flame.url' in labels &&
|
||||
/^app/.test(labels['flame.type'])
|
||||
) {
|
||||
for (let i = 0; i < labels['flame.name'].split(';').length; i++) {
|
||||
const names = labels['flame.name'].split(';');
|
||||
const urls = labels['flame.url'].split(';');
|
||||
let icons = '';
|
||||
|
||||
if ('flame.icon' in labels) {
|
||||
icons = labels['flame.icon'].split(';');
|
||||
}
|
||||
|
||||
dockerApps.push({
|
||||
name: names[i] || names[0],
|
||||
url: urls[i] || urls[0],
|
||||
icon: icons[i] || 'docker',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of containers
|
||||
try {
|
||||
if (host.includes('localhost')) {
|
||||
// Use default host
|
||||
function getDocker(){
|
||||
return axios.get(
|
||||
`http://${host}/containers/json?{"status":["running"]}`,
|
||||
{
|
||||
socketPath: '/var/run/docker.sock',
|
||||
}
|
||||
);
|
||||
}
|
||||
function getSwarm(){
|
||||
return axios.get(
|
||||
`http://${host}/services`,
|
||||
{
|
||||
socketPath: '/var/run/docker.sock',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[ containers, containers_swarm ] = await Promise.all(
|
||||
[getDocker(),
|
||||
getSwarm()
|
||||
]);
|
||||
let { data } = await axios.get(
|
||||
`http://${host}/containers/json?{"status":["running"]}`,
|
||||
{
|
||||
socketPath: '/var/run/docker.sock',
|
||||
}
|
||||
);
|
||||
|
||||
containers = data;
|
||||
} else {
|
||||
// Use custom host
|
||||
let { data } = await axios.get(
|
||||
|
@ -79,17 +37,20 @@ const useDocker = async (apps) => {
|
|||
logger.log(`Can't connect to the Docker API on ${host}`, 'ERROR');
|
||||
}
|
||||
|
||||
if (containers_swarm) {
|
||||
if (containers) {
|
||||
apps = await App.findAll({
|
||||
order: [[orderType, 'ASC']],
|
||||
});
|
||||
|
||||
services = containers_swarm.data;
|
||||
for (const service of services) {
|
||||
let labels = service.Spec.Labels;
|
||||
// Filter out containers without any annotations
|
||||
containers = containers.filter((e) => Object.keys(e.Labels).length !== 0);
|
||||
|
||||
labels['flame.name'] = service.Spec.Name;
|
||||
labels['flame.type'] = 'application';
|
||||
const dockerApps = [];
|
||||
|
||||
for (const container of containers) {
|
||||
let labels = container.Labels;
|
||||
|
||||
// Traefik labels for URL configuration
|
||||
if (!('flame.url' in labels)) {
|
||||
for (const label of Object.keys(labels)) {
|
||||
if (/^traefik.*.frontend.rule/.test(label)) {
|
||||
|
@ -120,96 +81,68 @@ const useDocker = async (apps) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
addApp(dockerApps, labels);
|
||||
}
|
||||
}
|
||||
|
||||
if (containers) {
|
||||
apps = await App.findAll({
|
||||
order: [[orderType, 'ASC']],
|
||||
});
|
||||
// add each container as flame formatted app
|
||||
if (
|
||||
'flame.name' in labels &&
|
||||
'flame.url' in labels &&
|
||||
/^app/.test(labels['flame.type'])
|
||||
) {
|
||||
for (let i = 0; i < labels['flame.name'].split(';').length; i++) {
|
||||
const names = labels['flame.name'].split(';');
|
||||
const urls = labels['flame.url'].split(';');
|
||||
let icons = '';
|
||||
|
||||
// Filter out containers without any annotations
|
||||
containers = containers.data.filter((e) => Object.keys(e.Labels).length !== 0);
|
||||
|
||||
for (const container of containers) {
|
||||
let labels = container.Labels;
|
||||
if(!('com.docker.stack.namespace' in labels)){
|
||||
console.log(container)
|
||||
labels['flame.name'] = container.Names[0];
|
||||
labels['flame.type'] = 'application';
|
||||
// Traefik labels for URL configuration
|
||||
if (!('flame.url' in labels)) {
|
||||
for (const label of Object.keys(labels)) {
|
||||
if (/^traefik.*.frontend.rule/.test(label)) {
|
||||
// Traefik 1.x
|
||||
let value = labels[label];
|
||||
|
||||
if (value.indexOf('Host') !== -1) {
|
||||
value = value.split('Host:')[1];
|
||||
labels['flame.url'] =
|
||||
'https://' + value.split(',').join(';https://');
|
||||
}
|
||||
} else if (/^traefik.*?\.rule/.test(label)) {
|
||||
// Traefik 2.x
|
||||
const value = labels[label];
|
||||
|
||||
if (value.indexOf('Host') !== -1) {
|
||||
const regex = /\`([a-zA-Z0-9\.\-]+)\`/g;
|
||||
const domains = [];
|
||||
|
||||
while ((match = regex.exec(value)) != null) {
|
||||
domains.push('http://' + match[1]);
|
||||
}
|
||||
|
||||
if (domains.length > 0) {
|
||||
labels['flame.url'] = domains.join(';');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ('flame.icon' in labels) {
|
||||
icons = labels['flame.icon'].split(';');
|
||||
}
|
||||
|
||||
dockerApps.push({
|
||||
name: names[i] || names[0],
|
||||
url: urls[i] || urls[0],
|
||||
icon: icons[i] || 'docker',
|
||||
});
|
||||
}
|
||||
}
|
||||
addApp(dockerApps, labels);
|
||||
}
|
||||
}
|
||||
|
||||
if (unpinStoppedApps) {
|
||||
for (const app of apps) {
|
||||
await app.update({ isPinned: false });
|
||||
if (unpinStoppedApps) {
|
||||
for (const app of apps) {
|
||||
await app.update({ isPinned: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of dockerApps) {
|
||||
// If app already exists, update it
|
||||
if (apps.some((app) => app.name === item.name)) {
|
||||
const app = apps.find((a) => a.name === item.name);
|
||||
|
||||
if (
|
||||
item.icon === 'custom' ||
|
||||
(item.icon === 'docker' && app.icon != 'docker')
|
||||
) {
|
||||
// update without overriding icon
|
||||
await app.update({
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
isPinned: true,
|
||||
});
|
||||
for (const item of dockerApps) {
|
||||
// If app already exists, update it
|
||||
if (apps.some((app) => app.name === item.name)) {
|
||||
const app = apps.find((a) => a.name === item.name);
|
||||
|
||||
if (
|
||||
item.icon === 'custom' ||
|
||||
(item.icon === 'docker' && app.icon != 'docker')
|
||||
) {
|
||||
// update without overriding icon
|
||||
await app.update({
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
isPinned: true,
|
||||
});
|
||||
} else {
|
||||
await app.update({
|
||||
...item,
|
||||
isPinned: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await app.update({
|
||||
// else create new app
|
||||
await App.create({
|
||||
...item,
|
||||
icon: item.icon === 'custom' ? 'docker' : item.icon,
|
||||
isPinned: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// else create new app
|
||||
await App.create({
|
||||
...item,
|
||||
icon: item.icon === 'custom' ? 'docker' : item.icon,
|
||||
isPinned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
module.exports = useDocker;
|
||||
|
|
|
@ -35,14 +35,14 @@ const useKubernetes = async (apps) => {
|
|||
const annotations = ingress.metadata.annotations;
|
||||
|
||||
if (
|
||||
'flame.pawelmalak/name' in annotations &&
|
||||
'flame.pawelmalak/url' in annotations &&
|
||||
/^app/.test(annotations['flame.pawelmalak/type'])
|
||||
'flame.georgesg/name' in annotations &&
|
||||
'flame.georgesg/url' in annotations &&
|
||||
/^app/.test(annotations['flame.georgesg/type'])
|
||||
) {
|
||||
kubernetesApps.push({
|
||||
name: annotations['flame.pawelmalak/name'],
|
||||
url: annotations['flame.pawelmalak/url'],
|
||||
icon: annotations['flame.pawelmalak/icon'] || 'kubernetes',
|
||||
name: annotations['flame.georgesg/name'],
|
||||
url: annotations['flame.georgesg/url'],
|
||||
icon: annotations['flame.georgesg/icon'] || 'kubernetes',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,10 @@ metadata:
|
|||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: ca-cluster-issuer
|
||||
flame.pawelmalak/name: flame
|
||||
flame.pawelmalak/url: dev.flame.shokohsc.home
|
||||
flame.pawelmalak/type: app
|
||||
flame.pawelmalak/icon: fire
|
||||
flame.georgesg/name: flame
|
||||
flame.georgesg/url: dev.flame.shokohsc.home
|
||||
flame.georgesg/type: app
|
||||
flame.georgesg/icon: fire
|
||||
spec:
|
||||
rules:
|
||||
- host: dev.flame.shokohsc.home
|
||||
|
|
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "flame",
|
||||
"version": "0.1.0",
|
||||
"version": "2.4.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flame",
|
||||
"version": "0.1.0",
|
||||
"version": "2.4.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^0.15.1",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "flame",
|
||||
"version": "0.1.0",
|
||||
"version": "2.4.0",
|
||||
"description": "Self-hosted start page",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -3,27 +3,31 @@ const axios = require('axios');
|
|||
const loadConfig = require('./loadConfig');
|
||||
|
||||
const getExternalWeather = async () => {
|
||||
const { WEATHER_API_KEY: secret, lat, long } = await loadConfig();
|
||||
const { WEATHER_API_KEY: secret, lat, long, isCelsius } = await loadConfig();
|
||||
|
||||
//units = standard, metric, imperial
|
||||
const units = isCelsius?'metric':'imperial'
|
||||
|
||||
// Fetch data from external API
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`http://api.weatherapi.com/v1/current.json?key=${secret}&q=${lat},${long}`
|
||||
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${long}&appid=${secret}&units=${units}`
|
||||
);
|
||||
|
||||
// Save weather data
|
||||
const cursor = res.data.current;
|
||||
const cursor = res.data;
|
||||
const isDay = (Math.floor(Date.now()/1000) < cursor.sys.sunset) | 0
|
||||
const weatherData = await Weather.create({
|
||||
externalLastUpdate: cursor.last_updated,
|
||||
tempC: cursor.temp_c,
|
||||
tempF: cursor.temp_f,
|
||||
isDay: cursor.is_day,
|
||||
cloud: cursor.cloud,
|
||||
conditionText: cursor.condition.text,
|
||||
conditionCode: cursor.condition.code,
|
||||
humidity: cursor.humidity,
|
||||
windK: cursor.wind_kph,
|
||||
windM: cursor.wind_mph,
|
||||
externalLastUpdate: cursor.dt,
|
||||
tempC: cursor.main.temp,
|
||||
tempF: cursor.main.temp,
|
||||
isDay: isDay,
|
||||
cloud: cursor.clouds.all,
|
||||
conditionText: cursor.weather[0].main,
|
||||
conditionCode: cursor.weather[0].id,
|
||||
humidity: cursor.main.humidity,
|
||||
windK: cursor.wind.speed,
|
||||
windM: 0,
|
||||
});
|
||||
return weatherData;
|
||||
} catch (err) {
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
"hideApps": false,
|
||||
"hideCategories": false,
|
||||
"hideSearch": false,
|
||||
"hideSearchProvider": false,
|
||||
"defaultSearchProvider": "l",
|
||||
"secondarySearchProvider": "d",
|
||||
"dockerApps": false,
|
||||
|
|
Loading…
Add table
Reference in a new issue