1
0
Fork 0
mirror of https://github.com/pawelmalak/flame.git synced 2025-07-19 03:29:37 +02:00

import from up to date repo

This commit is contained in:
Matthew Horwood 2022-11-30 20:46:28 +00:00
parent 35f5db62f2
commit 12baf72567
86 changed files with 12080 additions and 13746 deletions

View file

@ -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) ### v2.3.0 (2022-03-25)
- Added custom theme editor ([#246](https://github.com/pawelmalak/flame/issues/246)) - 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)) - Added option to set secondary search provider ([#295](https://github.com/pawelmalak/flame/issues/295))

View file

@ -2,6 +2,16 @@
![Homescreen screenshot](.github/home.png) ![Homescreen screenshot](.github/home.png)
## 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 ## 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. 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) ### With Docker (recommended)
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame) [Docker Hub link](https://hub.docker.com/r/mhzawadi/flame)
```sh ```sh
docker pull pawelmalak/flame docker pull mhzawadi/flame
# for ARM architecture (e.g. RaspberryPi) # for ARM architecture (e.g. RaspberryPi)
docker pull pawelmalak/flame:multiarch docker pull mhzawadi/flame:multiarch
# installing specific version # installing specific version
docker pull pawelmalak/flame:2.0.0 docker pull mhzawadi/flame:2.0.0
``` ```
#### Deployment #### Deployment
```sh ```sh
# run container # 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 #### Building images
@ -59,7 +69,7 @@ version: '3.6'
services: services:
flame: flame:
image: pawelmalak/flame image: mhzawadi/flame
container_name: flame container_name: flame
volumes: volumes:
- /path/to/host/data:/app/data - /path/to/host/data:/app/data
@ -123,7 +133,7 @@ Follow instructions from wiki: [Installation without Docker](https://github.com/
```sh ```sh
# clone repository # clone repository
git clone https://github.com/pawelmalak/flame git clone https://github.com/mhzawadi/flame
cd flame cd flame
# run only once # run only once
@ -219,10 +229,10 @@ In order to use the Kubernetes integration, each ingress must have the following
```yml ```yml
metadata: metadata:
annotations: annotations:
- flame.pawelmalak/type=application # "app" works too - flame.georgesg/type=application # "app" works too
- flame.pawelmalak/name=My container - flame.georgesg/name=My container
- flame.pawelmalak/url=https://example.com - flame.georgesg/url=https://example.com
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes" - 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 > "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker

View file

@ -1 +1 @@
REACT_APP_VERSION=2.3.0 REACT_APP_VERSION=2.4.0

21800
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,25 @@
{ {
"name": "client", "name": "client",
"version": "0.1.0", "version": "2.4.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@mdi/js": "^6.4.95", "@mdi/js": "^6.4.95",
"@mdi/react": "^1.5.0", "@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/jest-dom": "^5.15.0",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
@ -13,24 +28,9 @@
"@types/react": "^17.0.34", "@types/react": "^17.0.34",
"@types/react-beautiful-dnd": "^13.1.2", "@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.11", "@types/react-dom": "^17.0.11",
"@types/react-redux": "^7.1.20",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"axios": "^0.24.0", "prettier": "^2.4.1",
"external-svg-loader": "^1.3.4", "typescript": "^4.4.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"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@ -55,8 +55,5 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"prettier": "^2.4.1"
} }
} }

View file

@ -1,38 +1,43 @@
import 'external-svg-loader';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom'; 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 { Apps } from './components/Apps/Apps';
import { Settings } from './components/Settings/Settings';
import { Bookmarks } from './components/Bookmarks/Bookmarks'; import { Bookmarks } from './components/Bookmarks/Bookmarks';
import { Home } from './components/Home/Home';
import { NotificationCenter } from './components/NotificationCenter/NotificationCenter'; import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
import { Settings } from './components/Settings/Settings';
// Get config import { Spinner } from './components/UI';
store.dispatch<any>(getConfig()); import { useAutoLogin, useLogout } from './state/auth';
import { configAtom, configLoadingAtom, useFetchConfig } from './state/config';
// Validate token import { infoMessage, useCreateNotification } from './state/notification';
if (localStorage.token) { import { useFetchQueries } from './state/queries';
store.dispatch<any>(autoLogin()); import { useFetchThemes, useSetTheme } from './state/theme';
} import { decodeToken, parsePABToTheme, useCheckVersion } from './utility';
export const App = (): JSX.Element => { export const App = (): JSX.Element => {
const { config, loading } = useSelector((state: State) => state.config); const autoLogin = useAutoLogin();
const dispath = useDispatch(); // Validate token
const { fetchQueries, setTheme, logout, createNotification, fetchThemes } = if (localStorage.token) {
bindActionCreators(actionCreators, dispath); 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(() => { useEffect(() => {
// check if token is valid // check if token is valid
@ -43,10 +48,9 @@ export const App = (): JSX.Element => {
if (now > expiresIn) { if (now > expiresIn) {
logout(); logout();
createNotification({ createNotification(
title: 'Info', infoMessage('Session expired. You have been logged out')
message: 'Session expired. You have been logged out', );
});
} }
} }
}, 1000); }, 1000);
@ -75,6 +79,10 @@ export const App = (): JSX.Element => {
} }
}, [loading]); }, [loading]);
if (loading) {
return <Spinner />;
}
return ( return (
<> <>
<BrowserRouter> <BrowserRouter>

View file

@ -2,7 +2,15 @@
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; 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 { .AppCardIcon {
@ -11,15 +19,12 @@
margin-right: 0.5em; margin-right: 0.5em;
} }
.AppCardDetails {
text-transform: uppercase;
}
.AppCardDetails h5 { .AppCardDetails h5 {
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;
color: var(--color-primary); color: var(--color-primary);
margin-bottom: -4px; margin-bottom: -2px;
text-transform: uppercase;
} }
.AppCardDetails span { .AppCardDetails span {
@ -31,13 +36,10 @@
@media (min-width: 500px) { @media (min-width: 500px) {
.AppCard { .AppCard {
padding: 2px; padding: 5px 10px;
border-radius: 4px; border-radius: 8px;
transition: all 0.1s; transition: all 0.1s;
} margin-bottom: 20px;
.AppCard:hover {
background-color: rgba(0, 0, 0, 0.2);
} }
} }

View file

@ -1,17 +1,16 @@
import classes from './AppCard.module.css'; import { useAtomValue } from 'jotai';
import { Icon } from '../../UI';
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
import { App } from '../../../interfaces'; import { App } from '../../../interfaces';
import { useSelector } from 'react-redux'; import { configAtom } from '../../../state/config';
import { State } from '../../../store/reducers'; import { isImage, isSvg, isUrl, urlParser } from '../../../utility';
import { Icon } from '../../UI';
import classes from './AppCard.module.css';
interface Props { interface Props {
app: App; app: App;
} }
export const AppCard = ({ app }: Props): JSX.Element => { export const AppCard = ({ app }: Props): JSX.Element => {
const { config } = useSelector((state: State) => state.config); const config = useAtomValue(configAtom);
const [displayUrl, redirectUrl] = urlParser(app.url); const [displayUrl, redirectUrl] = urlParser(app.url);
@ -41,7 +40,7 @@ export const AppCard = ({ app }: Props): JSX.Element => {
</div> </div>
); );
} else { } else {
iconEl = <Icon icon={iconParser(icon)} />; iconEl = <Icon icon={icon} />;
} }
return ( return (

View file

@ -1,25 +1,22 @@
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react'; import { useAtom } from 'jotai';
import { useDispatch, useSelector } from 'react-redux'; import { ChangeEvent, SyntheticEvent, useEffect, useState } from 'react';
import { NewApp } from '../../../interfaces'; import { NewApp } from '../../../interfaces';
import { appInUpdateAtom, useAddApp, useUpdateApp } from '../../../state/app';
import classes from './AppForm.module.css'; import { useCreateNotification } from '../../../state/notification';
import { ModalForm, InputGroup, Button } from '../../UI';
import { inputHandler, newAppTemplate } from '../../../utility'; import { inputHandler, newAppTemplate } from '../../../utility';
import { bindActionCreators } from 'redux'; import { Button, InputGroup, ModalForm } from '../../UI';
import { actionCreators } from '../../../store'; import classes from './AppForm.module.css';
import { State } from '../../../store/reducers';
interface Props { interface Props {
modalHandler: () => void; modalHandler: () => void;
} }
export const AppForm = ({ modalHandler }: Props): JSX.Element => { export const AppForm = ({ modalHandler }: Props): JSX.Element => {
const { appInUpdate } = useSelector((state: State) => state.apps); const createNotification = useCreateNotification();
const dispatch = useDispatch(); const [appInUpdate, setEditApp] = useAtom(appInUpdateAtom);
const { addApp, updateApp, setEditApp, createNotification } = const addApp = useAddApp();
bindActionCreators(actionCreators, dispatch); const updateApp = useUpdateApp();
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false); const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null); const [customIcon, setCustomIcon] = useState<File | null>(null);
@ -164,11 +161,19 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
Use icon name from MDI or pass a valid URL. Use icon name from{' '}
<a href="https://materialdesignicons.com/" target="blank"> <a href="https://materialdesignicons.com/" target="blank">
{' '} MDI
Click here for reference
</a> </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>
<span <span
onClick={() => toggleUseCustomIcon(!useCustomIcon)} onClick={() => toggleUseCustomIcon(!useCustomIcon)}
@ -196,7 +201,7 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
}} }}
className={classes.Switch} className={classes.Switch}
> >
Switch to MDI Switch to icon name
</span> </span>
</InputGroup> </InputGroup>
)} )}

View file

@ -1,38 +1,41 @@
import { Fragment, useState, useEffect } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { import {
DragDropContext, DragDropContext,
Droppable,
Draggable, Draggable,
Droppable,
DropResult, DropResult,
} from 'react-beautiful-dnd'; } from 'react-beautiful-dnd';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// Redux // 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'; import { App } from '../../../interfaces';
import {
// Other appsAtom,
import { Message, Table } from '../../UI'; useDeleteApp,
usePinApp,
useReorderApps,
useUpdateApp,
} from '../../../state/app';
import { configAtom } from '../../../state/config';
import { useCreateNotification } from '../../../state/notification';
import { TableActions } from '../../Actions/TableActions'; import { TableActions } from '../../Actions/TableActions';
import { Message, Table } from '../../UI';
interface Props { interface Props {
openFormForUpdating: (app: App) => void; openFormForUpdating: (app: App) => void;
} }
export const AppTable = (props: Props): JSX.Element => { export const AppTable = (props: Props): JSX.Element => {
const { const config = useAtomValue(configAtom);
apps: { apps },
config: { config },
} = useSelector((state: State) => state);
const dispatch = useDispatch(); const apps = useAtomValue(appsAtom);
const { pinApp, deleteApp, reorderApps, createNotification, updateApp } = const pinApp = usePinApp();
bindActionCreators(actionCreators, dispatch); const deleteApp = useDeleteApp();
const reorderApps = useReorderApps();
const updateApp = useUpdateApp();
const createNotification = useCreateNotification();
const [localApps, setLocalApps] = useState<App[]>([]); const [localApps, setLocalApps] = useState<App[]>([]);
@ -87,7 +90,7 @@ export const AppTable = (props: Props): JSX.Element => {
}; };
return ( return (
<Fragment> <>
<Message isPrimary={false}> <Message isPrimary={false}>
{config.useOrdering === 'orderId' ? ( {config.useOrdering === 'orderId' ? (
<p>You can drag and drop single rows to reorder application</p> <p>You can drag and drop single rows to reorder application</p>
@ -155,6 +158,6 @@ export const AppTable = (props: Props): JSX.Element => {
)} )}
</Droppable> </Droppable>
</DragDropContext> </DragDropContext>
</Fragment> </>
); );
}; };

View file

@ -1,47 +1,36 @@
import { useAtomValue, useSetAtom } from 'jotai';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// Redux
import { useDispatch, useSelector } from 'react-redux';
// Typescript
import { App } from '../../interfaces'; import { App } from '../../interfaces';
import {
// CSS appInUpdateAtom,
import classes from './Apps.module.css'; appsAtom,
appsLoadingAtom,
// UI useFetchApps,
import { Headline, Spinner, ActionButton, Modal, Container } from '../UI'; } from '../../state/app';
import { authAtom } from '../../state/auth';
// Subcomponents import { ActionButton, Container, Headline, Modal, Spinner } from '../UI';
import { AppGrid } from './AppGrid/AppGrid';
import { AppForm } from './AppForm/AppForm'; import { AppForm } from './AppForm/AppForm';
import { AppGrid } from './AppGrid/AppGrid';
import classes from './Apps.module.css';
import { AppTable } from './AppTable/AppTable'; import { AppTable } from './AppTable/AppTable';
// Utils
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
interface Props { interface Props {
searching: boolean; searching: boolean;
} }
export const Apps = (props: Props): JSX.Element => { export const Apps = (props: Props): JSX.Element => {
// Get Redux state const { isAuthenticated } = useAtomValue(authAtom);
const {
apps: { apps, loading },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
// Get Redux action creators const apps = useAtomValue(appsAtom);
const dispatch = useDispatch(); const setEditApp = useSetAtom(appInUpdateAtom);
const { getApps, setEditApp } = bindActionCreators(actionCreators, dispatch); const loading = useAtomValue(appsLoadingAtom);
const fetchApps = useFetchApps();
// Load apps if array is empty // Load apps if array is empty
useEffect(() => { useEffect(() => {
if (!apps.length) { if (!apps.length) {
getApps(); fetchApps();
} }
}, []); }, []);

View file

@ -1,18 +1,12 @@
import { useAtomValue, useSetAtom } from 'jotai';
import { Fragment } from 'react'; 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'; import { Bookmark, Category } from '../../../interfaces';
import { authAtom } from '../../../state/auth';
// Other import { categoryInEditAtom } from '../../../state/bookmark';
import classes from './BookmarkCard.module.css'; import { configAtom } from '../../../state/config';
import { isImage, isSvg, isUrl, urlParser } from '../../../utility';
import { Icon } from '../../UI'; import { Icon } from '../../UI';
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; import classes from './BookmarkCard.module.css';
interface Props { interface Props {
category: Category; category: Category;
@ -22,13 +16,10 @@ interface Props {
export const BookmarkCard = (props: Props): JSX.Element => { export const BookmarkCard = (props: Props): JSX.Element => {
const { category, fromHomepage = false } = props; const { category, fromHomepage = false } = props;
const { const config = useAtomValue(configAtom);
config: { config }, const { isAuthenticated } = useAtomValue(authAtom);
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch(); const setCategoryInEdit = useSetAtom(categoryInEditAtom);
const { setEditCategory } = bindActionCreators(actionCreators, dispatch);
return ( return (
<div className={classes.BookmarkCard}> <div className={classes.BookmarkCard}>
@ -38,7 +29,7 @@ export const BookmarkCard = (props: Props): JSX.Element => {
} }
onClick={() => { onClick={() => {
if (!fromHomepage && isAuthenticated) { if (!fromHomepage && isAuthenticated) {
setEditCategory(category); setCategoryInEdit(category);
} }
}} }}
> >
@ -81,7 +72,7 @@ export const BookmarkCard = (props: Props): JSX.Element => {
} else { } else {
iconEl = ( iconEl = (
<div className={classes.BookmarkIcon}> <div className={classes.BookmarkIcon}>
<Icon icon={iconParser(icon)} /> <Icon icon={icon} />
</div> </div>
); );
} }

View file

@ -1,29 +1,26 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Bookmark, Category } from '../../interfaces';
// 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 { import {
ActionButton,
Container, Container,
Headline, Headline,
ActionButton,
Spinner,
Modal,
Message, Message,
Modal,
Spinner,
} from '../UI'; } from '../UI';
import classes from './Bookmarks.module.css';
// Components // 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 { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
import { Form } from './Form/Form'; import { Form } from './Form/Form';
import { Table } from './Table/Table'; import { Table } from './Table/Table';
@ -38,21 +35,19 @@ export enum ContentType {
} }
export const Bookmarks = (props: Props): JSX.Element => { export const Bookmarks = (props: Props): JSX.Element => {
// Get Redux state const { isAuthenticated } = useAtomValue(authAtom);
const {
bookmarks: { loading, categories, categoryInEdit },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
// Get Redux action creators const loading = useAtomValue(bookmarksLoadingAtom);
const dispatch = useDispatch(); const categories = useAtomValue(categoriesAtom);
const { getCategories, setEditCategory, setEditBookmark } = const [categoryInEdit, setCategoryInEdit] = useAtom(categoryInEditAtom);
bindActionCreators(actionCreators, dispatch); const setBookmarkInEdit = useSetAtom(bookmarkInEditAtom);
const fetchCategories = useFetchCategories();
// Load categories if array is empty // Load categories if array is empty
useEffect(() => { useEffect(() => {
if (!categories.length) { if (!categories.length) {
getCategories(); fetchCategories();
} }
}, []); }, []);
@ -84,7 +79,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
useEffect(() => { useEffect(() => {
setShowTable(false); setShowTable(false);
setEditCategory(null); setCategoryInEdit(null);
}, []); }, []);
// Form actions // Form actions
@ -107,10 +102,10 @@ export const Bookmarks = (props: Props): JSX.Element => {
if (instanceOfCategory(data)) { if (instanceOfCategory(data)) {
setFormContentType(ContentType.category); setFormContentType(ContentType.category);
setEditCategory(data); setCategoryInEdit(data);
} else { } else {
setFormContentType(ContentType.bookmark); setFormContentType(ContentType.bookmark);
setEditBookmark(data); setBookmarkInEdit(data);
} }
toggleModal(); toggleModal();
@ -120,7 +115,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
const showTableForEditing = (contentType: ContentType) => { const showTableForEditing = (contentType: ContentType) => {
// We're in the edit mode and the same button was clicked - go back to list // We're in the edit mode and the same button was clicked - go back to list
if (showTable && contentType === tableContentType) { if (showTable && contentType === tableContentType) {
setEditCategory(null); setBookmarkInEdit(null);
setShowTable(false); setShowTable(false);
} else { } else {
setShowTable(true); setShowTable(true);
@ -130,7 +125,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
const finishEditing = () => { const finishEditing = () => {
setShowTable(false); setShowTable(false);
setEditCategory(null); setBookmarkInEdit(null);
}; };
return ( return (
@ -176,9 +171,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
<Message isPrimary={false}> <Message isPrimary={false}>
Click on category name to edit its bookmarks Click on category name to edit its bookmarks
</Message> </Message>
) : ( ) : null}
<></>
)}
{loading ? ( {loading ? (
<Spinner /> <Spinner />

View file

@ -1,22 +1,19 @@
import { useState, ChangeEvent, useEffect, FormEvent } from 'react'; import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
// Redux // Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript // Typescript
import { useAtomValue } from 'jotai';
import { Bookmark, Category, NewBookmark } from '../../../interfaces'; import { Bookmark, Category, NewBookmark } from '../../../interfaces';
import {
// UI categoriesAtom,
import { ModalForm, InputGroup, Button } from '../../UI'; useAddBookmark,
useUpdateBookmark,
// CSS } from '../../../state/bookmark';
import classes from './Form.module.css'; import { useCreateNotification } from '../../../state/notification';
// Utils
import { inputHandler, newBookmarkTemplate } from '../../../utility'; import { inputHandler, newBookmarkTemplate } from '../../../utility';
import { Button, InputGroup, ModalForm } from '../../UI';
import classes from './Form.module.css';
interface Props { interface Props {
modalHandler: () => void; modalHandler: () => void;
@ -27,11 +24,11 @@ export const BookmarksForm = ({
bookmark, bookmark,
modalHandler, modalHandler,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { categories } = useSelector((state: State) => state.bookmarks); const createNotification = useCreateNotification();
const dispatch = useDispatch(); const categories = useAtomValue(categoriesAtom);
const { addBookmark, updateBookmark, createNotification } = const addBookmark = useAddBookmark();
bindActionCreators(actionCreators, dispatch); const updateBookmark = useUpdateBookmark();
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false); const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null); const [customIcon, setCustomIcon] = useState<File | null>(null);
@ -219,11 +216,19 @@ export const BookmarksForm = ({
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
Use icon name from MDI or pass a valid URL. Use icon name from{' '}
<a href="https://materialdesignicons.com/" target="blank"> <a href="https://materialdesignicons.com/" target="blank">
{' '} MDI
Click here for reference
</a> </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>
<span <span
onClick={() => toggleUseCustomIcon(!useCustomIcon)} onClick={() => toggleUseCustomIcon(!useCustomIcon)}
@ -250,7 +255,7 @@ export const BookmarksForm = ({
}} }}
className={classes.Switch} className={classes.Switch}
> >
Switch to MDI Switch to icon name
</span> </span>
</InputGroup> </InputGroup>
)} )}

View file

@ -1,18 +1,8 @@
import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; 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'; import { Category, NewCategory } from '../../../interfaces';
import { useAddCategory, useUpdateCategory } from '../../../state/bookmark';
// UI
import { ModalForm, InputGroup, Button } from '../../UI';
// Utils
import { inputHandler, newCategoryTemplate } from '../../../utility'; import { inputHandler, newCategoryTemplate } from '../../../utility';
import { Button, InputGroup, ModalForm } from '../../UI';
interface Props { interface Props {
modalHandler: () => void; modalHandler: () => void;
@ -23,11 +13,8 @@ export const CategoryForm = ({
category, category,
modalHandler, modalHandler,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const dispatch = useDispatch(); const addCategory = useAddCategory();
const { addCategory, updateCategory } = bindActionCreators( const updateCategory = useUpdateCategory();
actionCreators,
dispatch
);
const [formData, setFormData] = useState<NewCategory>(newCategoryTemplate); const [formData, setFormData] = useState<NewCategory>(newCategoryTemplate);

View file

@ -1,13 +1,12 @@
// Typescript import { useAtomValue } from 'jotai';
import { ContentType } from '../Bookmarks'; import {
bookmarkInEditAtom,
// Utils categoryInEditAtom,
import { CategoryForm } from './CategoryForm'; } from '../../../state/bookmark';
import { BookmarksForm } from './BookmarksForm';
import { Fragment } from 'react';
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bookmarkTemplate, categoryTemplate } from '../../../utility'; import { bookmarkTemplate, categoryTemplate } from '../../../utility';
import { ContentType } from '../Bookmarks';
import { BookmarksForm } from './BookmarksForm';
import { CategoryForm } from './CategoryForm';
interface Props { interface Props {
modalHandler: () => void; modalHandler: () => void;
@ -16,26 +15,25 @@ interface Props {
} }
export const Form = (props: Props): JSX.Element => { export const Form = (props: Props): JSX.Element => {
const { categoryInEdit, bookmarkInEdit } = useSelector( const categoryInEdit = useAtomValue(categoryInEditAtom);
(state: State) => state.bookmarks const bookmarkInEdit = useAtomValue(bookmarkInEditAtom);
);
const { modalHandler, contentType, inUpdate } = props; const { modalHandler, contentType, inUpdate } = props;
return ( return (
<Fragment> <>
{!inUpdate ? ( {!inUpdate ? (
// form: add new // form: add new
<Fragment> <>
{contentType === ContentType.category ? ( {contentType === ContentType.category ? (
<CategoryForm modalHandler={modalHandler} /> <CategoryForm modalHandler={modalHandler} />
) : ( ) : (
<BookmarksForm modalHandler={modalHandler} /> <BookmarksForm modalHandler={modalHandler} />
)} )}
</Fragment> </>
) : ( ) : (
// form: update // form: update
<Fragment> <>
{contentType === ContentType.category ? ( {contentType === ContentType.category ? (
<CategoryForm <CategoryForm
modalHandler={modalHandler} modalHandler={modalHandler}
@ -47,8 +45,8 @@ export const Form = (props: Props): JSX.Element => {
bookmark={bookmarkInEdit || bookmarkTemplate} bookmark={bookmarkInEdit || bookmarkTemplate}
/> />
)} )}
</Fragment> </>
)} )}
</Fragment> </>
); );
}; };

View file

@ -1,42 +1,37 @@
import { useState, useEffect, Fragment } from 'react'; import { useAtomValue } from 'jotai';
import { useEffect, useState } from 'react';
import { import {
DragDropContext, DragDropContext,
Droppable,
Draggable, Draggable,
Droppable,
DropResult, DropResult,
} from 'react-beautiful-dnd'; } 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'; import { Bookmark, Category } from '../../../interfaces';
import {
// UI categoryInEditAtom,
import { Message, Table } from '../../UI'; useDeleteBookmark,
import { TableActions } from '../../Actions/TableActions'; useReorderBookmarks,
useUpdateBookmark,
} from '../../../state/bookmark';
import { configAtom } from '../../../state/config';
import { useCreateNotification } from '../../../state/notification';
import { bookmarkTemplate } from '../../../utility'; import { bookmarkTemplate } from '../../../utility';
import { TableActions } from '../../Actions/TableActions';
import { Message, Table } from '../../UI';
interface Props { interface Props {
openFormForUpdating: (data: Category | Bookmark) => void; openFormForUpdating: (data: Category | Bookmark) => void;
} }
export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => { export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
const { const config = useAtomValue(configAtom);
bookmarks: { categoryInEdit },
config: { config },
} = useSelector((state: State) => state);
const dispatch = useDispatch(); const categoryInEdit = useAtomValue(categoryInEditAtom);
const { const deleteBookmark = useDeleteBookmark();
deleteBookmark, const updateBookmark = useUpdateBookmark();
updateBookmark, const reorderBookmarks = useReorderBookmarks();
createNotification,
reorderBookmarks, const createNotification = useCreateNotification();
} = bindActionCreators(actionCreators, dispatch);
const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]); const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]);
@ -103,7 +98,7 @@ export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
}; };
return ( return (
<Fragment> <>
{!categoryInEdit ? ( {!categoryInEdit ? (
<Message isPrimary={false}> <Message isPrimary={false}>
Switch to grid view and click on the name of category you want to edit 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> </Droppable>
</DragDropContext> </DragDropContext>
)} )}
</Fragment> </>
); );
}; };

View file

@ -1,43 +1,38 @@
import { useState, useEffect, Fragment } from 'react'; import { useAtomValue } from 'jotai';
import { useEffect, useState } from 'react';
import { import {
DragDropContext, DragDropContext,
Droppable,
Draggable, Draggable,
Droppable,
DropResult, DropResult,
} from 'react-beautiful-dnd'; } from 'react-beautiful-dnd';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { Bookmark, Category } from '../../../interfaces'; import { Bookmark, Category } from '../../../interfaces';
import {
// UI categoriesAtom,
import { Message, Table } from '../../UI'; useDeleteCategory,
usePinCategory,
useReorderCategories,
useUpdateCategory,
} from '../../../state/bookmark';
import { configAtom } from '../../../state/config';
import { useCreateNotification } from '../../../state/notification';
import { TableActions } from '../../Actions/TableActions'; import { TableActions } from '../../Actions/TableActions';
import { Message, Table } from '../../UI';
interface Props { interface Props {
openFormForUpdating: (data: Category | Bookmark) => void; openFormForUpdating: (data: Category | Bookmark) => void;
} }
export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => { export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
const { const config = useAtomValue(configAtom);
config: { config },
bookmarks: { categories },
} = useSelector((state: State) => state);
const dispatch = useDispatch(); const categories = useAtomValue(categoriesAtom);
const { const pinCategory = usePinCategory();
pinCategory, const deleteCategory = useDeleteCategory();
deleteCategory, const reorderCategories = useReorderCategories();
createNotification, const updateCategory = useUpdateCategory();
reorderCategories, const createNotification = useCreateNotification();
updateCategory,
} = bindActionCreators(actionCreators, dispatch);
const [localCategories, setLocalCategories] = useState<Category[]>([]); const [localCategories, setLocalCategories] = useState<Category[]>([]);
@ -95,7 +90,7 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
}; };
return ( return (
<Fragment> <>
<Message isPrimary={false}> <Message isPrimary={false}>
{config.useOrdering === 'orderId' ? ( {config.useOrdering === 'orderId' ? (
<p>You can drag and drop single rows to reorder categories</p> <p>You can drag and drop single rows to reorder categories</p>
@ -161,6 +156,6 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
)} )}
</Droppable> </Droppable>
</DragDropContext> </DragDropContext>
</Fragment> </>
); );
}; };

View file

@ -16,12 +16,14 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 2.5rem; margin-bottom: 60px;
} }
.SettingsLink { .SettingsLink {
display: inline-block;
visibility: visible; visibility: visible;
color: var(--color-accent); color: var(--color-accent);
margin-bottom: 10px;
} }
@media (min-width: 769px) { @media (min-width: 769px) {

View file

@ -1,24 +1,14 @@
import { useAtomValue } from 'jotai';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { configAtom } from '../../../state/config';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
// CSS
import classes from './Header.module.css';
// Components
import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget'; import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
// Utils
import { getDateTime } from './functions/getDateTime'; import { getDateTime } from './functions/getDateTime';
import { greeter } from './functions/greeter'; import { greeter } from './functions/greeter';
import classes from './Header.module.css';
export const Header = (): JSX.Element => { export const Header = (): JSX.Element => {
const { hideHeader, hideDate, showTime } = useSelector( const { hideHeader, hideDate, showTime } = useAtomValue(configAtom);
(state: State) => state.config.config
);
const [dateTime, setDateTime] = useState<string>(getDateTime()); const [dateTime, setDateTime] = useState<string>(getDateTime());
const [greeting, setGreeting] = useState<string>(greeter()); const [greeting, setGreeting] = useState<string>(greeter());
@ -36,12 +26,12 @@ export const Header = (): JSX.Element => {
return ( return (
<header className={classes.Header}> <header className={classes.Header}>
{(!hideDate || showTime) && <p>{dateTime}</p>}
<Link to="/settings" className={classes.SettingsLink}> <Link to="/settings" className={classes.SettingsLink}>
Go to Settings Go to Settings
</Link> </Link>
{(!hideDate || showTime) && <p>{dateTime}</p>}
{!hideHeader && ( {!hideHeader && (
<span className={classes.HeaderMain}> <span className={classes.HeaderMain}>
<h1>{greeting}</h1> <h1>{greeting}</h1>

View file

@ -48,23 +48,16 @@ export const getDateTime = (): string => {
} }
// Time // Time
const p = parseTime;
let timeEl = ''; let timeEl = '';
if (showTime) { if (showTime) {
const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p( timeEl = `${parseTime(now.getHours())}:${parseTime(
now.getSeconds() now.getMinutes()
)}`; )}:${parseTime(now.getSeconds())}`;
timeEl = time;
} }
// Separator // Separator
let separator = ''; const separator = !hideDate && showTime ? ' · ' : '';
if (!hideDate && showTime) {
separator = ' - ';
}
// Output // Output
return `${dateEl}${separator}${timeEl}`; return `${dateEl}${separator}${timeEl}`;

View file

@ -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'; 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'; import { App, Category } from '../../interfaces';
import { appsAtom, appsLoadingAtom, useFetchApps } from '../../state/app';
// UI import { authAtom } from '../../state/auth';
import { Icon, Container, SectionHeadline, Spinner, Message } from '../UI'; import {
bookmarksLoadingAtom,
// CSS categoriesAtom,
import classes from './Home.module.css'; useFetchCategories,
} from '../../state/bookmark';
// Components import { configAtom } from '../../state/config';
import { escapeRegex } from '../../utility';
import { AppGrid } from '../Apps/AppGrid/AppGrid'; import { AppGrid } from '../Apps/AppGrid/AppGrid';
import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid'; import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
import { SearchBar } from '../SearchBar/SearchBar'; import { SearchBar } from '../SearchBar/SearchBar';
import { Container, Icon, Message, SectionHeadline, Spinner } from '../UI';
import { Header } from './Header/Header'; import { Header } from './Header/Header';
import classes from './Home.module.css';
// Utils
import { escapeRegex } from '../../utility';
export const Home = (): JSX.Element => { export const Home = (): JSX.Element => {
const { const config = useAtomValue(configAtom);
apps: { apps, loading: appsLoading },
bookmarks: { categories, loading: bookmarksLoading },
config: { config },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch(); const { isAuthenticated } = useAtomValue(authAtom);
const { getApps, getCategories } = bindActionCreators(
actionCreators, const apps = useAtomValue(appsAtom);
dispatch const appsLoading = useAtomValue(appsLoadingAtom);
); const fetchApps = useFetchApps();
const categories = useAtomValue(categoriesAtom);
const bookmarksLoading = useAtomValue(bookmarksLoadingAtom);
const fetchCategories = useFetchCategories();
// Local search query // Local search query
const [localSearch, setLocalSearch] = useState<null | string>(null); const [localSearch, setLocalSearch] = useState<null | string>(null);
@ -46,17 +38,13 @@ export const Home = (): JSX.Element => {
null | Category[] null | Category[]
>(null); >(null);
// Load applications
useEffect(() => { useEffect(() => {
if (!apps.length) { if (!apps.length) {
getApps(); fetchApps();
} }
}, []);
// Load bookmark categories
useEffect(() => {
if (!categories.length) { 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>, Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>,
login and start customizing your new homepage login and start customizing your new homepage
</Message> </Message>
) : ( ) : null}
<></>
)}
{!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? ( {!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? (
<Fragment> <>
<SectionHeadline title="Applications" link="/applications" /> <SectionHeadline title="Applications" link="/applications" />
{appsLoading ? ( {appsLoading ? (
<Spinner /> <Spinner />
@ -131,14 +117,12 @@ export const Home = (): JSX.Element => {
/> />
)} )}
<div className={classes.HomeSpace}></div> <div className={classes.HomeSpace}></div>
</Fragment> </>
) : ( ) : null}
<></>
)}
{!config.hideCategories && {!config.hideCategories &&
(isAuthenticated || categories.some((c) => c.isPinned)) ? ( (isAuthenticated || categories.some((c) => c.isPinned)) ? (
<Fragment> <>
<SectionHeadline title="Bookmarks" link="/bookmarks" /> <SectionHeadline title="Bookmarks" link="/bookmarks" />
{bookmarksLoading ? ( {bookmarksLoading ? (
<Spinner /> <Spinner />
@ -156,10 +140,8 @@ export const Home = (): JSX.Element => {
fromHomepage={true} fromHomepage={true}
/> />
)} )}
</Fragment> </>
) : ( ) : null}
<></>
)}
<Link to="/settings" className={classes.SettingsButton}> <Link to="/settings" className={classes.SettingsButton}>
<Icon icon="mdiCog" color="var(--color-background)" /> <Icon icon="mdiCog" color="var(--color-background)" />

View file

@ -1,13 +1,11 @@
import { useSelector } from 'react-redux'; import { useAtomValue } from 'jotai';
import { Notification as NotificationInterface } from '../../interfaces'; import { Notification as NotificationInterface } from '../../interfaces';
import { notificationsAtom } from '../../state/notification';
import { Notification } from '../UI';
import classes from './NotificationCenter.module.css'; import classes from './NotificationCenter.module.css';
import { Notification } from '../UI';
import { State } from '../../store/reducers';
export const NotificationCenter = (): JSX.Element => { export const NotificationCenter = (): JSX.Element => {
const { notifications } = useSelector((state: State) => state.notification); const { notifications } = useAtomValue(notificationsAtom);
return ( return (
<div <div

View file

@ -1,9 +1,9 @@
import { useSelector } from 'react-redux'; import { useAtomValue } from 'jotai';
import { Redirect, Route, RouteProps } from 'react-router'; import { Redirect, Route, RouteProps } from 'react-router';
import { State } from '../../store/reducers'; import { authAtom } from '../../state/auth';
export const ProtectedRoute = ({ ...rest }: RouteProps) => { export const ProtectedRoute = ({ ...rest }: RouteProps) => {
const { isAuthenticated } = useSelector((state: State) => state.auth); const { isAuthenticated } = useAtomValue(authAtom);
if (isAuthenticated) { if (isAuthenticated) {
return <Route {...rest} />; return <Route {...rest} />;

View file

@ -1,17 +1,21 @@
.SearchProvider {
color: var(--color-accent);
}
.SearchBar { .SearchBar {
width: 100%; width: 100%;
padding: 10px 0; padding: 10px 0;
color: var(--color-primary); color: var(--color-primary);
/* font-size: 20px; */ margin-bottom: 80px;
margin-bottom: 20px;
background-color: transparent; background-color: transparent;
border: none; border: none;
border-bottom: 2px solid var(--color-accent); border-bottom: 2px solid var(--color-accent);
opacity: 0.5; opacity: 0.5;
transition: all 0.2s; transition: all 0.2s;
border-radius: 0px;
} }
.SearchBar:focus { .SearchBar:focus {
opacity: 1; opacity: 1;
outline: none; outline: none;
} }

View file

@ -1,20 +1,11 @@
import { useRef, useEffect, KeyboardEvent } from 'react'; import { useAtomValue } from 'jotai';
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
// Typescript
import { App, Category } from '../../interfaces'; import { App, Category } from '../../interfaces';
import { configAtom, configLoadingAtom } from '../../state/config';
// CSS import { useCreateNotification } from '../../state/notification';
import { redirectUrl, urlParser, useSearchParser } from '../../utility';
import classes from './SearchBar.module.css'; 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 { interface Props {
setLocalSearch: (query: string) => void; setLocalSearch: (query: string) => void;
appSearchResult: App[] | null; appSearchResult: App[] | null;
@ -22,15 +13,20 @@ interface Props {
} }
export const SearchBar = (props: Props): JSX.Element => { 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 = useCreateNotification();
const { createNotification } = bindActionCreators(actionCreators, dispatch);
const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props; const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
const inputRef = useRef<HTMLInputElement>(document.createElement('input')); const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
const [searchProvider, setSearchProvider] = useState(
searchParser('').primarySearch.name
);
// Search bar autofocus // Search bar autofocus
useEffect(() => { useEffect(() => {
if (!loading && !config.disableAutofocus) { if (!loading && !config.disableAutofocus) {
@ -78,6 +74,10 @@ export const SearchBar = (props: Props): JSX.Element => {
setLocalSearch(encodedURL); setLocalSearch(encodedURL);
} }
if (primarySearch.name) {
setSearchProvider(primarySearch.name);
}
if (e.code === 'Enter' || e.code === 'NumpadEnter') { if (e.code === 'Enter' || e.code === 'NumpadEnter') {
if (!primarySearch.prefix) { if (!primarySearch.prefix) {
// Prefix not found -> emit notification // Prefix not found -> emit notification
@ -113,6 +113,7 @@ export const SearchBar = (props: Props): JSX.Element => {
const url = `${primarySearch.template}${encodedURL}`; const url = `${primarySearch.template}${encodedURL}`;
redirectUrl(url, sameTab); redirectUrl(url, sameTab);
} }
if (config.autoClearSearch) clearSearch();
} else if (e.code === 'Escape') { } else if (e.code === 'Escape') {
clearSearch(); clearSearch();
} }
@ -120,6 +121,9 @@ export const SearchBar = (props: Props): JSX.Element => {
return ( return (
<div className={classes.SearchContainer}> <div className={classes.SearchContainer}>
{!config.hideSearchProvider && (
<span className={classes.SearchProvider}>{searchProvider}</span>
)}
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"

View file

@ -1,34 +1,28 @@
import { Fragment } from 'react'; import { useAtomValue } from 'jotai';
import { authAtom } from '../../../state/auth';
// UI import { useCheckVersion } from '../../../utility';
import { Button, SettingsHeadline } from '../../UI'; import { Button, SettingsHeadline } from '../../UI';
import { AuthForm } from './AuthForm/AuthForm';
import classes from './AppDetails.module.css'; import classes from './AppDetails.module.css';
import { AuthForm } from './AuthForm/AuthForm';
// Store
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
// Other
import { checkVersion } from '../../../utility';
export const AppDetails = (): JSX.Element => { export const AppDetails = (): JSX.Element => {
const { isAuthenticated } = useSelector((state: State) => state.auth); const { isAuthenticated } = useAtomValue(authAtom);
const checkVersion = useCheckVersion(true);
return ( return (
<Fragment> <>
<SettingsHeadline text="Authentication" /> <SettingsHeadline text="Authentication" />
<AuthForm /> <AuthForm />
{isAuthenticated && ( {isAuthenticated && (
<Fragment> <>
<hr className={classes.separator} /> <hr className={classes.separator} />
<div> <div>
<SettingsHeadline text="App version" /> <SettingsHeadline text="App version" />
<p className={classes.text}> <p className={classes.text}>
<a <a
href="https://github.com/pawelmalak/flame" href="https://github.com/GeorgeSG/flame"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@ -40,7 +34,7 @@ export const AppDetails = (): JSX.Element => {
<p className={classes.text}> <p className={classes.text}>
See changelog{' '} See changelog{' '}
<a <a
href="https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md" href="https://github.com/GeorgeSG/flame/blob/master/CHANGELOG.md"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@ -48,10 +42,10 @@ export const AppDetails = (): JSX.Element => {
</a> </a>
</p> </p>
<Button click={() => checkVersion(true)}>Check for updates</Button> <Button click={checkVersion}>Check for updates</Button>
</div> </div>
</Fragment> </>
)} )}
</Fragment> </>
); );
}; };

View file

@ -1,21 +1,14 @@
import { FormEvent, Fragment, useEffect, useState, useRef } from 'react'; import { useAtomValue } from 'jotai';
import { FormEvent, useEffect, useRef, useState } from 'react';
// Redux import { authAtom, useLogin, useLogout } from '../../../../state/auth';
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
import { decodeToken, parseTokenExpire } from '../../../../utility'; import { decodeToken, parseTokenExpire } from '../../../../utility';
import { Button, InputGroup } from '../../../UI';
// Other
import { InputGroup, Button } from '../../../UI';
import classes from '../AppDetails.module.css'; import classes from '../AppDetails.module.css';
export const AuthForm = (): JSX.Element => { export const AuthForm = (): JSX.Element => {
const { isAuthenticated, token } = useSelector((state: State) => state.auth); const { isAuthenticated, token } = useAtomValue(authAtom);
const login = useLogin();
const dispatch = useDispatch(); const logout = useLogout();
const { login, logout } = bindActionCreators(actionCreators, dispatch);
const [tokenExpires, setTokenExpires] = useState(''); const [tokenExpires, setTokenExpires] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@ -47,7 +40,7 @@ export const AuthForm = (): JSX.Element => {
}; };
return ( return (
<Fragment> <>
{!isAuthenticated ? ( {!isAuthenticated ? (
<form onSubmit={formHandler}> <form onSubmit={formHandler}>
<InputGroup> <InputGroup>
@ -105,6 +98,6 @@ export const AuthForm = (): JSX.Element => {
<Button click={logout}>Logout</Button> <Button click={logout}>Logout</Button>
</div> </div>
)} )}
</Fragment> </>
); );
}; };

View file

@ -1,25 +1,19 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; import { useAtomValue } from 'jotai';
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 { DockerSettingsForm } from '../../../interfaces'; import { DockerSettingsForm } from '../../../interfaces';
import {
// UI configAtom,
import { InputGroup, Button, SettingsHeadline } from '../../UI'; configLoadingAtom,
useUpdateConfig,
// Utils } from '../../../state/config';
import { inputHandler, dockerSettingsTemplate } from '../../../utility'; import { dockerSettingsTemplate, inputHandler } from '../../../utility';
import { Button, InputGroup, SettingsHeadline } from '../../UI';
import { Checkbox } from '../../UI/Checkbox/Checkbox';
export const DockerSettings = (): JSX.Element => { export const DockerSettings = (): JSX.Element => {
const { loading, config } = useSelector((state: State) => state.config); const loading = useAtomValue(configLoadingAtom);
const config = useAtomValue(configAtom);
const dispatch = useDispatch(); const updateConfig = useUpdateConfig();
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
// Initial state // Initial state
const [formData, setFormData] = useState<DockerSettingsForm>( 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 ( return (
<form onSubmit={(e) => formSubmitHandler(e)}> <form onSubmit={(e) => formSubmitHandler(e)}>
<SettingsHeadline text="Docker" /> <SettingsHeadline text="Docker" />
@ -71,49 +68,37 @@ export const DockerSettings = (): JSX.Element => {
</InputGroup> </InputGroup>
{/* USE DOCKER API */} {/* USE DOCKER API */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="dockerApps">Use Docker API</label> <Checkbox
<select
id="dockerApps" id="dockerApps"
name="dockerApps" checked={formData.dockerApps}
value={formData.dockerApps ? 1 : 0} onClick={() => onBooleanToggle('dockerApps')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="dockerApps">Use Docker API</label>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup> </InputGroup>
{/* UNPIN DOCKER APPS */} {/* UNPIN DOCKER APPS */}
<InputGroup> <InputGroup type="horizontal">
<Checkbox
id="unpinStoppedApps"
checked={formData.unpinStoppedApps}
onClick={() => onBooleanToggle('unpinStoppedApps')}
/>
<label htmlFor="unpinStoppedApps"> <label htmlFor="unpinStoppedApps">
Unpin stopped containers / other apps Unpin stopped containers / other apps
</label> </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> </InputGroup>
{/* KUBERNETES SETTINGS */} {/* KUBERNETES SETTINGS */}
<SettingsHeadline text="Kubernetes" /> <SettingsHeadline text="Kubernetes" />
{/* USE KUBERNETES */} {/* USE KUBERNETES */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label> <Checkbox
<select
id="kubernetesApps" id="kubernetesApps"
name="kubernetesApps" checked={formData.kubernetesApps}
value={formData.kubernetesApps ? 1 : 0} onClick={() => onBooleanToggle('kubernetesApps')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup> </InputGroup>
<Button>Save changes</Button> <Button>Save changes</Button>

View file

@ -1,28 +1,17 @@
import { useAtomValue } from 'jotai';
import { Fragment, useState } from 'react'; 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'; import { Query } from '../../../../interfaces';
import { configAtom } from '../../../../state/config';
// UI import { useCreateNotification } from '../../../../state/notification';
import { Modal, Icon, Button, CompactTable, ActionIcons } from '../../../UI'; import { customQueriesAtom, useDeleteQuery } from '../../../../state/queries';
import { ActionIcons, Button, CompactTable, Icon, Modal } from '../../../UI';
// Components
import { QueriesForm } from './QueriesForm'; import { QueriesForm } from './QueriesForm';
export const CustomQueries = (): JSX.Element => { export const CustomQueries = (): JSX.Element => {
const { customQueries, config } = useSelector((state: State) => state.config); const customQueries = useAtomValue(customQueriesAtom);
const deleteQuery = useDeleteQuery();
const dispatch = useDispatch(); const config = useAtomValue(configAtom);
const { deleteQuery, createNotification } = bindActionCreators( const createNotification = useCreateNotification();
actionCreators,
dispatch
);
const [modalIsOpen, setModalIsOpen] = useState(false); const [modalIsOpen, setModalIsOpen] = useState(false);
const [editableQuery, setEditableQuery] = useState<Query | null>(null); const [editableQuery, setEditableQuery] = useState<Query | null>(null);
@ -49,7 +38,7 @@ export const CustomQueries = (): JSX.Element => {
}; };
return ( return (
<Fragment> <>
<Modal <Modal
isOpen={modalIsOpen} isOpen={modalIsOpen}
setIsOpen={() => setModalIsOpen(!modalIsOpen)} setIsOpen={() => setModalIsOpen(!modalIsOpen)}
@ -82,9 +71,7 @@ export const CustomQueries = (): JSX.Element => {
</Fragment> </Fragment>
))} ))}
</CompactTable> </CompactTable>
) : ( ) : null}
<></>
)}
<Button <Button
click={() => { click={() => {
@ -95,6 +82,6 @@ export const CustomQueries = (): JSX.Element => {
Add new search provider Add new search provider
</Button> </Button>
</section> </section>
</Fragment> </>
); );
}; };

View file

@ -1,11 +1,6 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react'; import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { Query } from '../../../../interfaces'; import { Query } from '../../../../interfaces';
import { useAddQuery, useUpdateQuery } from '../../../../state/queries';
import { Button, InputGroup, ModalForm } from '../../../UI'; import { Button, InputGroup, ModalForm } from '../../../UI';
interface Props { interface Props {
@ -14,11 +9,8 @@ interface Props {
} }
export const QueriesForm = (props: Props): JSX.Element => { export const QueriesForm = (props: Props): JSX.Element => {
const dispatch = useDispatch(); const addQuery = useAddQuery();
const { addQuery, updateQuery } = bindActionCreators( const updateQuery = useUpdateQuery();
actionCreators,
dispatch
);
const { modalHandler, query } = props; const { modalHandler, query } = props;

View file

@ -1,36 +1,36 @@
// React import { useAtomValue } from 'jotai';
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react'; import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { GeneralForm, Query } from '../../../interfaces';
import { useSetSortedApps } from '../../../state/app';
// Typescript import {
import { Query, GeneralForm } from '../../../interfaces'; categoriesAtom,
useSetSortedCategories,
// Components 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'; 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 => { export const GeneralSettings = (): JSX.Element => {
const { const config = useAtomValue(configAtom);
config: { loading, customQueries, config }, const loading = useAtomValue(configLoadingAtom);
bookmarks: { categories },
} = useSelector((state: State) => state);
const dispatch = useDispatch(); const updateConfig = useUpdateConfig();
const { updateConfig, sortApps, sortCategories, sortBookmarks } = const customQueries = useAtomValue(customQueriesAtom);
bindActionCreators(actionCreators, dispatch);
const setSortedApps = useSetSortedApps();
const categories = useAtomValue(categoriesAtom);
const setSortedCategories = useSetSortedCategories();
const setSortedBookmarks = useSetSortedBookmarks();
// Initial state // Initial state
const [formData, setFormData] = useState<GeneralForm>( const [formData, setFormData] = useState<GeneralForm>(
@ -53,15 +53,18 @@ export const GeneralSettings = (): JSX.Element => {
// Sort entities with new settings // Sort entities with new settings
if (formData.useOrdering !== config.useOrdering) { if (formData.useOrdering !== config.useOrdering) {
sortApps(); setSortedApps();
sortCategories(); setSortedCategories();
for (let { id } of categories) { for (let { id } of categories) {
sortBookmarks(id); setSortedBookmarks(id);
} }
} }
}; };
const onBooleanToggle = (prop: keyof GeneralForm) =>
setFormData((prev) => ({ ...prev, [prop]: !prev[prop] }));
// Input handler // Input handler
const inputChangeHandler = ( const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
@ -76,7 +79,7 @@ export const GeneralSettings = (): JSX.Element => {
}; };
return ( return (
<Fragment> <>
<form <form
onSubmit={(e) => formSubmitHandler(e)} onSubmit={(e) => formSubmitHandler(e)}
style={{ marginBottom: '30px' }} style={{ marginBottom: '30px' }}
@ -101,67 +104,52 @@ export const GeneralSettings = (): JSX.Element => {
{/* === APPS OPTIONS === */} {/* === APPS OPTIONS === */}
<SettingsHeadline text="Apps" /> <SettingsHeadline text="Apps" />
{/* PIN APPS */} {/* PIN APPS */}
<InputGroup> <InputGroup type="horizontal">
<Checkbox
id="pinAppsByDefault"
name="pinAppsByDefault"
checked={formData.pinAppsByDefault}
onClick={() => onBooleanToggle('pinAppsByDefault')}
/>
<label htmlFor="pinAppsByDefault"> <label htmlFor="pinAppsByDefault">
Pin new applications by default Pin new applications by default
</label> </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> </InputGroup>
{/* APPS OPPENING */} {/* APPS OPPENING */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="appsSameTab">Open applications in the same tab</label> <Checkbox
<select
id="appsSameTab" id="appsSameTab"
name="appsSameTab" checked={formData.appsSameTab}
value={formData.appsSameTab ? 1 : 0} onClick={() => onBooleanToggle('appsSameTab')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="appsSameTab">Open applications in the same tab</label>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup> </InputGroup>
{/* === BOOKMARKS OPTIONS === */} {/* === BOOKMARKS OPTIONS === */}
<SettingsHeadline text="Bookmarks" /> <SettingsHeadline text="Bookmarks" />
{/* PIN CATEGORIES */} {/* PIN CATEGORIES */}
<InputGroup> <InputGroup type="horizontal">
<Checkbox
id="pinCategoriesByDefault"
checked={formData.pinCategoriesByDefault}
onClick={() => onBooleanToggle('pinCategoriesByDefault')}
/>
<label htmlFor="pinCategoriesByDefault"> <label htmlFor="pinCategoriesByDefault">
Pin new categories by default Pin new categories by default
</label> </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> </InputGroup>
{/* BOOKMARKS OPPENING */} {/* BOOKMARKS OPPENING */}
<InputGroup> <InputGroup type="horizontal">
<Checkbox
id="bookmarksSameTab"
checked={formData.bookmarksSameTab}
onClick={() => onBooleanToggle('bookmarksSameTab')}
/>
<label htmlFor="bookmarksSameTab"> <label htmlFor="bookmarksSameTab">
Open bookmarks in the same tab Open bookmarks in the same tab
</label> </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> </InputGroup>
{/* === SEARCH OPTIONS === */} {/* === SEARCH OPTIONS === */}
@ -214,19 +202,15 @@ export const GeneralSettings = (): JSX.Element => {
</InputGroup> </InputGroup>
)} )}
<InputGroup> <InputGroup type="horizontal">
<Checkbox
id="searchSameTab"
checked={formData.searchSameTab}
onClick={() => onBooleanToggle('searchSameTab')}
/>
<label htmlFor="searchSameTab"> <label htmlFor="searchSameTab">
Open search results in the same tab Open search results in the same tab
</label> </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> </InputGroup>
<Button>Save changes</Button> <Button>Save changes</Button>
@ -235,6 +219,6 @@ export const GeneralSettings = (): JSX.Element => {
{/* CUSTOM QUERIES */} {/* CUSTOM QUERIES */}
<SettingsHeadline text="Custom search providers" /> <SettingsHeadline text="Custom search providers" />
<CustomQueries /> <CustomQueries />
</Fragment> </>
); );
}; };

View file

@ -16,6 +16,7 @@
align-items: center; align-items: center;
height: 40px; height: 40px;
transition: all 0.3s; transition: all 0.3s;
cursor: pointer;
} }
.SettingsNavLink:hover, .SettingsNavLink:hover,
@ -37,4 +38,4 @@
.Settings { .Settings {
grid-template-columns: 1fr 3fr; grid-template-columns: 1fr 3fr;
} }
} }

View file

@ -1,33 +1,21 @@
import { NavLink, Link, Switch, Route } from 'react-router-dom'; import { useAtomValue } from 'jotai';
import { Link, NavLink, Route, Switch } from 'react-router-dom';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../store/reducers';
// Typescript
import { Route as SettingsRoute } from '../../interfaces'; import { Route as SettingsRoute } from '../../interfaces';
import { authAtom } from '../../state/auth';
// 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 { ProtectedRoute } from '../Routing/ProtectedRoute'; import { ProtectedRoute } from '../Routing/ProtectedRoute';
// UI
import { Container, Headline } from '../UI'; import { Container, Headline } from '../UI';
import { AppDetails } from './AppDetails/AppDetails';
// Data import { DockerSettings } from './DockerSettings/DockerSettings';
import { GeneralSettings } from './GeneralSettings/GeneralSettings';
import { routes } from './settings.json'; 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 => { 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); const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);

View file

@ -1,21 +1,12 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import axios from 'axios'; import axios from 'axios';
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
// Redux
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { ApiResponse } from '../../../interfaces'; import { ApiResponse } from '../../../interfaces';
import { useCreateNotification } from '../../../state/notification';
// Other
import { InputGroup, Button } from '../../UI';
import { applyAuth } from '../../../utility'; import { applyAuth } from '../../../utility';
import { Button, InputGroup } from '../../UI';
export const StyleSettings = (): JSX.Element => { export const StyleSettings = (): JSX.Element => {
const dispatch = useDispatch(); const createNotification = useCreateNotification();
const { createNotification } = bindActionCreators(actionCreators, dispatch);
const [customStyles, setCustomStyles] = useState<string>(''); const [customStyles, setCustomStyles] = useState<string>('');

View file

@ -1,15 +1,8 @@
import { useState, useEffect } from 'react'; import { useAtom, useAtomValue } from 'jotai';
import { useEffect, useState } from 'react';
// Redux
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
// Other
import { Theme } from '../../../../interfaces'; import { Theme } from '../../../../interfaces';
import { authAtom } from '../../../../state/auth';
// UI import { themeInEditAtom, userThemesAtom } from '../../../../state/theme';
import { Button, Modal } from '../../../UI'; import { Button, Modal } from '../../../UI';
import { ThemeGrid } from '../ThemeGrid/ThemeGrid'; import { ThemeGrid } from '../ThemeGrid/ThemeGrid';
import classes from './ThemeBuilder.module.css'; import classes from './ThemeBuilder.module.css';
@ -21,16 +14,26 @@ interface Props {
} }
export const ThemeBuilder = ({ themes }: Props): JSX.Element => { export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
const { const { isAuthenticated } = useAtomValue(authAtom);
auth: { isAuthenticated },
theme: { themeInEdit, userThemes },
} = useSelector((state: State) => state);
const { editTheme } = bindActionCreators(actionCreators, useDispatch()); const [themeInEdit, setThemeInEdit] = useAtom(themeInEditAtom);
const userThemes = useAtomValue(userThemesAtom);
const [showModal, toggleShowModal] = useState(false); const [showModal, toggleShowModal] = useState(false);
const [isInEdit, toggleIsInEdit] = 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(() => { useEffect(() => {
if (themeInEdit) { if (themeInEdit) {
toggleIsInEdit(false); toggleIsInEdit(false);
@ -51,7 +54,7 @@ export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
<Modal <Modal
isOpen={showModal} isOpen={showModal}
setIsOpen={() => toggleShowModal(!showModal)} setIsOpen={() => toggleShowModal(!showModal)}
cb={() => editTheme(null)} cb={() => setThemeInEdit(null)}
> >
{isInEdit ? ( {isInEdit ? (
<ThemeEditor modalHandler={() => toggleShowModal(!showModal)} /> <ThemeEditor modalHandler={() => toggleShowModal(!showModal)} />
@ -66,28 +69,10 @@ export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
{/* BUTTONS */} {/* BUTTONS */}
{isAuthenticated && ( {isAuthenticated && (
<div className={classes.Buttons}> <div className={classes.Buttons}>
<Button <Button click={showCreate}>Create new theme</Button>
click={() => {
editTheme(null);
toggleIsInEdit(false);
toggleShowModal(!showModal);
}}
>
Create new theme
</Button>
{themes.length ? ( {themes.length ? (
<Button <Button click={showEdit}>Edit user themes</Button>
click={() => { ) : null}
toggleIsInEdit(true);
toggleShowModal(!showModal);
}}
>
Edit user themes
</Button>
) : (
<></>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -1,31 +1,24 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react'; import { useAtom, useAtomValue } from 'jotai';
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
// Redux import { Theme } from '../../../../interfaces';
import { useDispatch, useSelector } from 'react-redux'; import {
import { bindActionCreators } from 'redux'; activeThemeAtom,
import { actionCreators } from '../../../../store'; themeInEditAtom,
import { State } from '../../../../store/reducers'; useAddTheme,
useUpdateTheme,
// UI } from '../../../../state/theme';
import { Button, InputGroup, ModalForm } from '../../../UI'; import { Button, InputGroup, ModalForm } from '../../../UI';
import classes from './ThemeCreator.module.css'; import classes from './ThemeCreator.module.css';
// Other
import { Theme } from '../../../../interfaces';
interface Props { interface Props {
modalHandler: () => void; modalHandler: () => void;
} }
export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => { export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
const { const activeTheme = useAtomValue(activeThemeAtom);
theme: { activeTheme, themeInEdit }, const [themeInEdit, setThemeInEdit] = useAtom(themeInEditAtom);
} = useSelector((state: State) => state); const addTheme = useAddTheme();
const updateTheme = useUpdateTheme();
const { addTheme, updateTheme, editTheme } = bindActionCreators(
actionCreators,
useDispatch()
);
const [formData, setFormData] = useState<Theme>({ const [formData, setFormData] = useState<Theme>({
name: '', name: '',
@ -37,9 +30,10 @@ export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
}, },
}); });
useEffect(() => { useEffect(
setFormData({ ...formData, colors: activeTheme.colors }); () => setFormData({ ...formData, colors: activeTheme.colors }),
}, [activeTheme]); [activeTheme]
);
useEffect(() => { useEffect(() => {
if (themeInEdit) { if (themeInEdit) {
@ -69,7 +63,7 @@ export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
}; };
const closeModal = () => { const closeModal = () => {
editTheme(null); setThemeInEdit(null);
modalHandler(); modalHandler();
}; };

View file

@ -1,32 +1,25 @@
import { useAtomValue, useSetAtom } from 'jotai';
import { Fragment } from 'react'; import { Fragment } from 'react';
// Redux
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { Theme } from '../../../../interfaces'; import { Theme } from '../../../../interfaces';
import { actionCreators } from '../../../../store'; import {
import { State } from '../../../../store/reducers'; themeInEditAtom,
useDeleteTheme,
// Other userThemesAtom,
} from '../../../../state/theme';
import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI'; import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI';
interface Props { interface Props {
modalHandler: () => void; modalHandler: () => void;
} }
export const ThemeEditor = (props: Props): JSX.Element => { export const ThemeEditor = ({ modalHandler }: Props): JSX.Element => {
const { const userThemes = useAtomValue(userThemesAtom);
theme: { userThemes }, const setThemeInEdit = useSetAtom(themeInEditAtom);
} = useSelector((state: State) => state); const deleteTheme = useDeleteTheme();
const { deleteTheme, editTheme } = bindActionCreators(
actionCreators,
useDispatch()
);
const updateHandler = (theme: Theme) => { const updateHandler = (theme: Theme) => {
props.modalHandler(); modalHandler();
editTheme(theme); setThemeInEdit(theme);
}; };
const deleteHandler = (theme: Theme) => { const deleteHandler = (theme: Theme) => {
@ -36,7 +29,7 @@ export const ThemeEditor = (props: Props): JSX.Element => {
}; };
return ( return (
<ModalForm formHandler={() => {}} modalHandler={props.modalHandler}> <ModalForm formHandler={() => {}} modalHandler={modalHandler}>
<CompactTable headers={['Name', 'Actions']}> <CompactTable headers={['Name', 'Actions']}>
{userThemes.map((t, idx) => ( {userThemes.map((t, idx) => (
<Fragment key={idx}> <Fragment key={idx}>

View file

@ -1,8 +1,5 @@
// Components
import { ThemePreview } from '../ThemePreview/ThemePreview';
// Other
import { Theme } from '../../../../interfaces'; import { Theme } from '../../../../interfaces';
import { ThemePreview } from '../ThemePreview/ThemePreview';
import classes from './ThemeGrid.module.css'; import classes from './ThemeGrid.module.css';
interface Props { interface Props {

View file

@ -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 { Theme } from '../../../../interfaces/Theme';
import { useSetTheme } from '../../../../state/theme';
import classes from './ThemePreview.module.css'; import classes from './ThemePreview.module.css';
interface Props { interface Props {
@ -14,7 +9,7 @@ interface Props {
export const ThemePreview = ({ export const ThemePreview = ({
theme: { colors, name }, theme: { colors, name },
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { setTheme } = bindActionCreators(actionCreators, useDispatch()); const setTheme = useSetTheme();
return ( return (
<div className={classes.ThemePreview} onClick={() => setTheme(colors)}> <div className={classes.ThemePreview} onClick={() => setTheme(colors)}>

View file

@ -1,35 +1,31 @@
import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react'; import { useAtomValue } from 'jotai';
import { ChangeEvent, FormEvent, 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 { Theme, ThemeSettingsForm } from '../../../interfaces'; import { Theme, ThemeSettingsForm } from '../../../interfaces';
import { authAtom } from '../../../state/auth';
// Components import {
import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI'; configAtom,
import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder'; configLoadingAtom,
import { ThemeGrid } from './ThemeGrid/ThemeGrid'; useUpdateConfig,
} from '../../../state/config';
// Other import { themesAtom, userThemesAtom } from '../../../state/theme';
import { import {
inputHandler, inputHandler,
parseThemeToPAB, parseThemeToPAB,
themeSettingsTemplate, themeSettingsTemplate,
} from '../../../utility'; } from '../../../utility';
import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
import { ThemeGrid } from './ThemeGrid/ThemeGrid';
export const Themer = (): JSX.Element => { export const Themer = (): JSX.Element => {
const { const { isAuthenticated } = useAtomValue(authAtom);
auth: { isAuthenticated },
config: { loading, config },
theme: { themes, userThemes },
} = useSelector((state: State) => state);
const dispatch = useDispatch(); const themes = useAtomValue(themesAtom);
const { updateConfig } = bindActionCreators(actionCreators, dispatch); const userThemes = useAtomValue(userThemesAtom);
const loading = useAtomValue(configLoadingAtom);
const config = useAtomValue(configAtom);
const updateConfig = useUpdateConfig();
// Initial state // Initial state
const [formData, setFormData] = useState<ThemeSettingsForm>( const [formData, setFormData] = useState<ThemeSettingsForm>(
@ -37,11 +33,7 @@ export const Themer = (): JSX.Element => {
); );
// Get config // Get config
useEffect(() => { useEffect(() => setFormData({ ...config }), [loading]);
setFormData({
...config,
});
}, [loading]);
// Form handler // Form handler
const formSubmitHandler = async (e: FormEvent) => { const formSubmitHandler = async (e: FormEvent) => {
@ -65,14 +57,14 @@ export const Themer = (): JSX.Element => {
}; };
const customThemesEl = ( const customThemesEl = (
<Fragment> <>
<SettingsHeadline text="User themes" /> <SettingsHeadline text="User themes" />
<ThemeBuilder themes={userThemes} /> <ThemeBuilder themes={userThemes} />
</Fragment> </>
); );
return ( return (
<Fragment> <>
<SettingsHeadline text="App themes" /> <SettingsHeadline text="App themes" />
{!themes.length ? <Spinner /> : <ThemeGrid themes={themes} />} {!themes.length ? <Spinner /> : <ThemeGrid themes={themes} />}
@ -100,6 +92,6 @@ export const Themer = (): JSX.Element => {
<Button>Save changes</Button> <Button>Save changes</Button>
</form> </form>
)} )}
</Fragment> </>
); );
}; };

View file

@ -1,25 +1,19 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; import { useAtomValue } from 'jotai';
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 { UISettingsForm } from '../../../interfaces'; import { UISettingsForm } from '../../../interfaces';
import {
// UI configAtom,
import { InputGroup, Button, SettingsHeadline } from '../../UI'; configLoadingAtom,
useUpdateConfig,
// Utils } from '../../../state/config';
import { uiSettingsTemplate, inputHandler } from '../../../utility'; import { inputHandler, uiSettingsTemplate } from '../../../utility';
import { Button, InputGroup, SettingsHeadline } from '../../UI';
import { Checkbox } from '../../UI/Checkbox/Checkbox';
export const UISettings = (): JSX.Element => { export const UISettings = (): JSX.Element => {
const { loading, config } = useSelector((state: State) => state.config); const loading = useAtomValue(configLoadingAtom);
const config = useAtomValue(configAtom);
const dispatch = useDispatch(); const updateConfig = useUpdateConfig();
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
// Initial state // Initial state
const [formData, setFormData] = useState<UISettingsForm>(uiSettingsTemplate); 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 ( return (
<form onSubmit={(e) => formSubmitHandler(e)}> <form onSubmit={(e) => formSubmitHandler(e)}>
{/* === OTHER OPTIONS === */} {/* === OTHER OPTIONS === */}
@ -75,77 +72,77 @@ export const UISettings = (): JSX.Element => {
{/* === SEARCH OPTIONS === */} {/* === SEARCH OPTIONS === */}
<SettingsHeadline text="Search" /> <SettingsHeadline text="Search" />
{/* HIDE SEARCHBAR */} {/* HIDE SEARCHBAR */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="hideSearch">Hide search bar</label> <Checkbox
<select
id="hideSearch" id="hideSearch"
name="hideSearch" checked={formData.hideSearch}
value={formData.hideSearch ? 1 : 0} onClick={() => onBooleanToggle('hideSearch')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="hideSearch">Hide search bar</label>
<option value={1}>True</option> </InputGroup>
<option value={0}>False</option> {/* HIDE SEARCH PROVIDER*/}
</select> <InputGroup type="horizontal">
<Checkbox
id="hideSearchProvider"
checked={formData.hideSearchProvider}
onClick={() => onBooleanToggle('hideSearchProvider')}
/>
<label htmlFor="hideSearchProvider">Hide search provider label</label>
</InputGroup> </InputGroup>
{/* AUTOFOCUS SEARCHBAR */} {/* AUTOFOCUS SEARCHBAR */}
<InputGroup> <InputGroup type="horizontal">
<Checkbox
id="appsSameTab"
checked={formData.disableAutofocus}
onClick={() => onBooleanToggle('disableAutofocus')}
/>
<label htmlFor="disableAutofocus">Disable search bar autofocus</label> <label htmlFor="disableAutofocus">Disable search bar autofocus</label>
<select </InputGroup>
id="disableAutofocus"
name="disableAutofocus" <InputGroup type="horizontal">
value={formData.disableAutofocus ? 1 : 0} <Checkbox
onChange={(e) => inputChangeHandler(e, { isBool: true })} id="autoClearSearch"
> checked={formData.autoClearSearch}
<option value={1}>True</option> onClick={() => onBooleanToggle('autoClearSearch')}
<option value={0}>False</option> />
</select> <label htmlFor="autoClearSearch">
Automatically clear the search bar
</label>
</InputGroup> </InputGroup>
{/* === HEADER OPTIONS === */} {/* === HEADER OPTIONS === */}
<SettingsHeadline text="Header" /> <SettingsHeadline text="Header" />
{/* HIDE HEADER */} {/* HIDE HEADER */}
<InputGroup> <InputGroup type="horizontal">
<Checkbox
id="hideHeader"
checked={formData.hideHeader}
onClick={() => onBooleanToggle('hideHeader')}
/>
<label htmlFor="hideHeader"> <label htmlFor="hideHeader">
Hide headline (greetings and weather) Hide headline (greetings and weather)
</label> </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> </InputGroup>
{/* HIDE DATE */} {/* HIDE DATE */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="hideDate">Hide date</label> <Checkbox
<select
id="hideDate" id="hideDate"
name="hideDate" checked={formData.hideDate}
value={formData.hideDate ? 1 : 0} onClick={() => onBooleanToggle('hideDate')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="hideDate">Hide date</label>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup> </InputGroup>
{/* HIDE TIME */} {/* HIDE TIME */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="showTime">Hide time</label> <Checkbox
<select
id="showTime" id="showTime"
name="showTime" checked={!formData.showTime}
value={formData.showTime ? 1 : 0} onClick={() => onBooleanToggle('showTime')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="showTime">Hide time</label>
<option value={0}>True</option>
<option value={1}>False</option>
</select>
</InputGroup> </InputGroup>
{/* DATE FORMAT */} {/* DATE FORMAT */}
@ -210,31 +207,23 @@ export const UISettings = (): JSX.Element => {
{/* === SECTIONS OPTIONS === */} {/* === SECTIONS OPTIONS === */}
<SettingsHeadline text="Sections" /> <SettingsHeadline text="Sections" />
{/* HIDE APPS */} {/* HIDE APPS */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="hideApps">Hide applications</label> <Checkbox
<select
id="hideApps" id="hideApps"
name="hideApps" checked={formData.hideApps}
value={formData.hideApps ? 1 : 0} onClick={() => onBooleanToggle('hideApps')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="hideApps">Hide applications</label>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup> </InputGroup>
{/* HIDE CATEGORIES */} {/* HIDE CATEGORIES */}
<InputGroup> <InputGroup type="horizontal">
<label htmlFor="hideCategories">Hide categories</label> <Checkbox
<select
id="hideCategories" id="hideCategories"
name="hideCategories" checked={formData.hideCategories}
value={formData.hideCategories ? 1 : 0} onClick={() => onBooleanToggle('hideCategories')}
onChange={(e) => inputChangeHandler(e, { isBool: true })} />
> <label htmlFor="hideCategories">Hide categories</label>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup> </InputGroup>
<Button>Save changes</Button> <Button>Save changes</Button>

View file

@ -1,29 +1,22 @@
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useAtomValue } from 'jotai';
// Redux import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
// Typescript
import { ApiResponse, Weather, WeatherForm } from '../../../interfaces'; import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
import {
// UI configAtom,
import { InputGroup, Button, SettingsHeadline } from '../../UI'; configLoadingAtom,
useUpdateConfig,
// Utils } from '../../../state/config';
import { useCreateNotification } from '../../../state/notification';
import { inputHandler, weatherSettingsTemplate } from '../../../utility'; import { inputHandler, weatherSettingsTemplate } from '../../../utility';
import { Button, InputGroup, SettingsHeadline } from '../../UI';
export const WeatherSettings = (): JSX.Element => { 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 = useCreateNotification();
const { createNotification, updateConfig } = bindActionCreators(
actionCreators,
dispatch
);
// Initial state // Initial state
const [formData, setFormData] = useState<WeatherForm>( const [formData, setFormData] = useState<WeatherForm>(
@ -110,10 +103,10 @@ export const WeatherSettings = (): JSX.Element => {
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
Using Now using
<a href="https://www.weatherapi.com/pricing.aspx" target="blank"> <a href="https://openweathermap.org/api" target="blank">
{' '} {' '}
Weather API OpenWeatherMap
</a> </a>
. Key is required for weather module to work. . Key is required for weather module to work.
</span> </span>

View file

@ -1,8 +1,7 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import classes from './ActionButton.module.css';
import { Icon } from '../..'; import { Icon } from '../..';
import classes from './ActionButton.module.css';
interface Props { interface Props {
name: string; name: string;
@ -13,12 +12,12 @@ interface Props {
export const ActionButton = (props: Props): JSX.Element => { export const ActionButton = (props: Props): JSX.Element => {
const body = ( const body = (
<Fragment> <>
<div className={classes.ActionButtonIcon}> <div className={classes.ActionButtonIcon}>
<Icon icon={props.icon} /> <Icon icon={props.icon} />
</div> </div>
<div className={classes.ActionButtonName}>{props.name}</div> <div className={classes.ActionButtonName}>{props.name}</div>
</Fragment> </>
); );
if (props.link) { if (props.link) {

View 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;
}

View 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>
);
};

View file

@ -2,6 +2,16 @@
margin-bottom: 15px; margin-bottom: 15px;
} }
.InputGroup.Horizontal {
display: flex;
align-items: center;
gap: 8px;
}
.InputGroup label {
cursor: pointer;
}
.InputGroup label, .InputGroup label,
.InputGroup span, .InputGroup span,
.InputGroup input, .InputGroup input,

View file

@ -1,10 +1,23 @@
import C from 'classnames';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import classes from './InputGroup.module.css'; import classes from './InputGroup.module.css';
interface Props { interface Props {
type?: 'vertical' | 'horizontal';
children: ReactNode; children: ReactNode;
} }
export const InputGroup = (props: Props): JSX.Element => { export const InputGroup = ({
return <div className={classes.InputGroup}>{props.children}</div>; type = 'vertical',
children,
}: Props): JSX.Element => {
return (
<div
className={C(classes.InputGroup, {
[classes.Horizontal]: type === 'horizontal',
})}
>
{children}
</div>
);
}; };

View file

@ -1,7 +1,6 @@
import { ReactNode, SyntheticEvent } from 'react'; import { ReactNode, SyntheticEvent } from 'react';
import classes from './ModalForm.module.css';
import { Icon } from '../..'; import { Icon } from '../..';
import classes from './ModalForm.module.css';
interface ComponentProps { interface ComponentProps {
children: ReactNode; children: ReactNode;

View file

@ -8,11 +8,11 @@ interface Props {
export const Headline = (props: Props): JSX.Element => { export const Headline = (props: Props): JSX.Element => {
return ( return (
<Fragment> <>
<h1 className={classes.HeadlineTitle}>{props.title}</h1> <h1 className={classes.HeadlineTitle}>{props.title}</h1>
{props.subtitle && ( {props.subtitle && (
<p className={classes.HeadlineSubtitle}>{props.subtitle}</p> <p className={classes.HeadlineSubtitle}>{props.subtitle}</p>
)} )}
</Fragment> </>
); );
}; };

View file

@ -4,4 +4,9 @@
font-weight: 900; font-weight: 900;
font-size: 20px; font-size: 20px;
margin-bottom: 16px; margin-bottom: 16px;
} transition: color 0.2s;
}
.SectionHeadlineLink:hover .SectionHeadline {
color: var(--color-accent);
}

View file

@ -1,5 +1,4 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import classes from './SectionHeadline.module.css'; import classes from './SectionHeadline.module.css';
interface Props { interface Props {
@ -9,7 +8,7 @@ interface Props {
export const SectionHeadline = (props: Props): JSX.Element => { export const SectionHeadline = (props: Props): JSX.Element => {
return ( return (
<Link to={props.link}> <Link to={props.link} className={classes.SectionHeadlineLink}>
<h2 className={classes.SectionHeadline}>{props.title}</h2> <h2 className={classes.SectionHeadline}>{props.title}</h2>
</Link> </Link>
); );

View file

@ -1,4 +1,4 @@
const classes = require('./SettingsHeadline.module.css'); import classes from './SettingsHeadline.module.css';
interface Props { interface Props {
text: string; text: string;

View file

@ -2,3 +2,14 @@
color: var(--color-primary); color: var(--color-primary);
width: 90%; width: 90%;
} }
.AppIconWrapper {
display: flex;
align-items: center;
height: 35px;
width: 35px;
}
.AppIcon {
height: 31px;
}

View file

@ -1,26 +1,34 @@
import classes from './Icon.module.css';
import { Icon as MDIcon } from '@mdi/react'; import { Icon as MDIcon } from '@mdi/react';
import { iconParser } from '../../../../utility';
import classes from './Icon.module.css';
interface Props { interface Props {
icon: string; icon: string;
color?: string; color?: string;
} }
export const Icon = (props: Props): JSX.Element => { export const Icon = ({ icon, color }: Props): JSX.Element => {
const MDIcons = require('@mdi/js'); const MDIcons = require('@mdi/js');
let iconPath = MDIcons[props.icon]; const mdiIcon = iconParser(icon);
let mdiIconPath = MDIcons[mdiIcon];
if (!iconPath) { if (!mdiIconPath) {
console.log(`Icon ${props.icon} not found`); return (
iconPath = MDIcons.mdiCancel; <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)'}
/>
);
}; };

View file

@ -13,345 +13,10 @@ export enum TimeOfDay {
night night
} }
const mapFromJson = require('./WeatherMapping.json')
export class IconMapping { export class IconMapping {
private conditions: WeatherCondition[] = [ private conditions: WeatherCondition[] = mapFromJson.mapping
{
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'
}
}
];
mapIcon(weatherStatusCode: number, timeOfDay: TimeOfDay): IconKey { mapIcon(weatherStatusCode: number, timeOfDay: TimeOfDay): IconKey {
const mapping = this.conditions.find((condition: WeatherCondition) => condition.code === weatherStatusCode); const mapping = this.conditions.find((condition: WeatherCondition) => condition.code === weatherStatusCode);

View file

@ -1,7 +1,7 @@
import { useAtomValue } from 'jotai';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Skycons } from 'skycons-ts'; import { Skycons } from 'skycons-ts';
import { State } from '../../../../store/reducers'; import { activeThemeAtom } from '../../../../state/theme';
import { IconMapping, TimeOfDay } from './IconMapping'; import { IconMapping, TimeOfDay } from './IconMapping';
interface Props { interface Props {
@ -10,7 +10,7 @@ interface Props {
} }
export const WeatherIcon = (props: Props): JSX.Element => { export const WeatherIcon = (props: Props): JSX.Element => {
const { activeTheme } = useSelector((state: State) => state.theme); const activeTheme = useAtomValue(activeThemeAtom);
const icon = props.isDay const icon = props.isDay
? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day) ? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)

View file

@ -1,340 +1,382 @@
{ {
"mapping": [ "mapping": [
{ {
"code": 1000, "code": 800,
"icon": { "icon": {
"day": "clear-day", "day": "clear-day",
"night": "clear-night" "night": "clear-night"
} }
}, },
{ {
"code": 1003, "code": 801,
"icon": { "icon": {
"day": "partly-cloudy-day", "day": "partly-cloudy-day",
"night": "partly-cloudy-night" "night": "partly-cloudy-night"
} }
}, },
{ {
"code": 1006, "code": 802,
"icon": {
"day": "partly-cloudy-day",
"night": "partly-cloudy-night"
}
},
{
"code": 803,
"icon": { "icon": {
"day": "cloudy", "day": "cloudy",
"night": "cloudy" "night": "cloudy"
} }
}, },
{ {
"code": 1009, "code": 804,
"icon": { "icon": {
"day": "cloudy", "day": "cloudy",
"night": "cloudy" "night": "cloudy"
} }
}, },
{ {
"code": 1030, "code": 701,
"icon": { "icon": {
"day": "fog", "day": "fog",
"night": "fog" "night": "fog"
} }
}, },
{ {
"code": 1063, "code": 711,
"icon": { "icon": {
"day": "rain-day", "day": "fog",
"night": "rain-night" "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": { "icon": {
"day": "snow-day", "day": "snow-day",
"night": "snow-night" "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": { "icon": {
"day": "rain-snow-day", "day": "rain-snow-day",
"night": "rain-snow-night" "night": "rain-snow-night"
} }
}, },
{ {
"code": 1072, "code": 616,
"icon": { "icon": {
"day": "sleet", "day": "rain-snow-day",
"night": "sleet" "night": "rain-snow-night"
} }
}, },
{ {
"code": 1087, "code": 620,
"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": { "icon": {
"day": "rain-snow", "day": "rain-snow",
"night": "rain-snow" "night": "rain-snow"
} }
}, },
{ {
"code": 1207, "code": 621,
"icon": { "icon": {
"day": "rain-snow", "day": "rain-snow",
"night": "rain-snow" "night": "rain-snow"
} }
}, },
{ {
"code": 1210, "code": 622,
"icon": { "icon": {
"day": "snow-day", "day": "rain-snow",
"night": "snow-night" "night": "rain-snow"
} }
}, },
{ {
"code": 1213, "code": 500,
"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": { "icon": {
"day": "rain-day", "day": "rain-day",
"night": "rain-night" "night": "rain-night"
} }
}, },
{ {
"code": 1243, "code": 501,
"icon": { "icon": {
"day": "rain-day", "day": "rain-day",
"night": "rain-night" "night": "rain-night"
} }
}, },
{ {
"code": 1246, "code": 502,
"icon": { "icon": {
"day": "rain-day", "day": "rain-day",
"night": "rain-night" "night": "rain-night"
} }
}, },
{ {
"code": 1249, "code": 503,
"icon": { "icon": {
"day": "rain-snow-day", "day": "rain",
"night": "rain-snow-night" "night": "rain"
} }
}, },
{ {
"code": 1252, "code": 504,
"icon": { "icon": {
"day": "rain-snow-day", "day": "rain",
"night": "rain-snow-night" "night": "rain"
} }
}, },
{ {
"code": 1255, "code": 511,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 1258,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 1261,
"icon": { "icon": {
"day": "hail", "day": "hail",
"night": "hail" "night": "hail"
} }
}, },
{ {
"code": 1264, "code": 520,
"icon": { "icon": {
"day": "hail", "day": "rain-day",
"night": "hail" "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": { "icon": {
"day": "thunder-rain-day", "day": "thunder-rain-day",
"night": "thunder-rain-night" "night": "thunder-rain-night"
} }
}, },
{ {
"code": 1276, "code": 201,
"icon": { "icon": {
"day": "thunder-rain", "day": "thunder-rain-day",
"night": "thunder-rain" "night": "thunder-rain-night"
} }
}, },
{ {
"code": 1279, "code": 202,
"icon": {
"day": "thunder-rain-day",
"night": "thunder-rain-night"
}
},
{
"code": 210,
"icon": { "icon": {
"day": "thunder-day", "day": "thunder-day",
"night": "thunder-night" "night": "thunder-night"
} }
}, },
{ {
"code": 1282, "code": 211,
"icon": {
"day": "thunder-day",
"night": "thunder-night"
}
},
{
"code": 212,
"icon": { "icon": {
"day": "thunder", "day": "thunder",
"night": "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"
}
} }
] ]
} }

View file

@ -1,5 +1,4 @@
import { MouseEvent, ReactNode, useRef } from 'react'; import { MouseEvent, ReactNode, useRef } from 'react';
import classes from './Modal.module.css'; import classes from './Modal.module.css';
interface Props { interface Props {
@ -9,6 +8,7 @@ interface Props {
cb?: Function; cb?: Function;
} }
// TODO: refactor cb + setIsOpen into onClose()
export const Modal = ({ export const Modal = ({
isOpen, isOpen,
setIsOpen, setIsOpen,

View file

@ -1,8 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux'; import { useClearNotification } from '../../../state/notification';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import classes from './Notification.module.css'; import classes from './Notification.module.css';
interface Props { interface Props {
@ -13,8 +10,7 @@ interface Props {
} }
export const Notification = (props: Props): JSX.Element => { export const Notification = (props: Props): JSX.Element => {
const dispatch = useDispatch(); const clearNotification = useClearNotification();
const { clearNotification } = bindActionCreators(actionCreators, dispatch);
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const elementClasses = [ const elementClasses = [

View file

@ -1,5 +1,4 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import classes from './Message.module.css'; import classes from './Message.module.css';
interface Props { interface Props {

View file

@ -1,27 +1,17 @@
import { useState, useEffect, Fragment } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useAtomValue } from 'jotai';
// Redux import { Fragment, useEffect, useState } from 'react';
import { useSelector } from 'react-redux'; import { ApiResponse, Weather } from '../../../interfaces';
import { configAtom, configLoadingAtom } from '../../../state/config';
// Typescript import { weatherTemplate } from '../../../utility/templateObjects/weatherTemplate';
import { Weather, ApiResponse } from '../../../interfaces'; import { WeatherIcon } from '../../UI';
// CSS
import classes from './WeatherWidget.module.css'; 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 => { export const WeatherWidget = (): JSX.Element => {
const { loading: configLoading, config } = useSelector( const configLoading = useAtomValue(configLoadingAtom);
(state: State) => state.config const config = useAtomValue(configAtom);
);
const [weather, setWeather] = useState<Weather>(weatherTemplate); const [weather, setWeather] = useState<Weather>(weatherTemplate);
const [isLoading, setIsLoading] = useState(true);
// Initial request to get data // Initial request to get data
useEffect(() => { useEffect(() => {
@ -32,7 +22,6 @@ export const WeatherWidget = (): JSX.Element => {
if (weatherData) { if (weatherData) {
setWeather(weatherData); setWeather(weatherData);
} }
setIsLoading(false);
}) })
.catch((err) => console.log(err)); .catch((err) => console.log(err));
}, []); }, []);
@ -59,7 +48,7 @@ export const WeatherWidget = (): JSX.Element => {
<div className={classes.WeatherWidget}> <div className={classes.WeatherWidget}>
{configLoading || {configLoading ||
(config.WEATHER_API_KEY && weather.id > 0 && ( (config.WEATHER_API_KEY && weather.id > 0 && (
<Fragment> <>
<div className={classes.WeatherIcon}> <div className={classes.WeatherIcon}>
<WeatherIcon <WeatherIcon
weatherStatusCode={weather.conditionCode} weatherStatusCode={weather.conditionCode}
@ -75,9 +64,12 @@ export const WeatherWidget = (): JSX.Element => {
)} )}
{/* ADDITIONAL DATA */} {/* ADDITIONAL DATA */}
<span>{weather[config.weatherData]}%</span> <span>
{weather.conditionText} · 
{weather[config.weatherData]}%
</span>
</div> </div>
</Fragment> </>
))} ))}
</div> </div>
); );

View file

@ -2,16 +2,11 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import './index.css'; import './index.css';
import { Provider } from 'react-redux';
import { store } from './store/store';
import { App } from './App'; import { App } from './App';
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<Provider store={store}> <App />
<App />
</Provider>
</React.StrictMode>, </React.StrictMode>,
document.getElementById('root') document.getElementById('root')
); );

View file

@ -16,8 +16,10 @@ export interface Config {
hideApps: boolean; hideApps: boolean;
hideCategories: boolean; hideCategories: boolean;
hideSearch: boolean; hideSearch: boolean;
hideSearchProvider: boolean;
defaultSearchProvider: string; defaultSearchProvider: string;
secondarySearchProvider: string; secondarySearchProvider: string;
autoClearSearch: boolean;
dockerApps: boolean; dockerApps: boolean;
dockerHost: string; dockerHost: string;
kubernetesApps: boolean; kubernetesApps: boolean;

View file

@ -31,7 +31,9 @@ export interface UISettingsForm {
showTime: boolean; showTime: boolean;
hideDate: boolean; hideDate: boolean;
hideSearch: boolean; hideSearch: boolean;
hideSearchProvider: boolean;
disableAutofocus: boolean; disableAutofocus: boolean;
autoClearSearch: boolean;
} }
export interface DockerSettingsForm { export interface DockerSettingsForm {

155
client/src/state/app.ts Normal file
View 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
View 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();
};
};

View 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);
};
};

View 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);
}
};
};

View 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),
}));
};
};

View 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
View 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')
);
}
};
};

View 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),
];

View file

@ -1,34 +1,32 @@
import axios from 'axios'; import axios from 'axios';
import { store } from '../store/store'; import { useCreateNotification } from '../state/notification';
import { createNotification } from '../store/action-creators';
export const checkVersion = async (isForced: boolean = false) => { export const useCheckVersion = (isForced: boolean = false) => {
try { const createNotification = useCreateNotification();
const res = await axios.get<string>( return async () => {
'https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env' try {
); const res = await axios.get<string>(
'https://raw.githubusercontent.com/GeorgeSG/flame/master/client/.env'
);
const githubVersion = res.data const githubVersion = res.data
.split('\n') .split('\n')
.map((pair) => pair.split('='))[0][1]; .map((pair) => pair.split('='))[0][1];
if (githubVersion !== process.env.REACT_APP_VERSION) { if (githubVersion !== process.env.REACT_APP_VERSION) {
store.dispatch<any>(
createNotification({ createNotification({
title: 'Info', title: 'Info',
message: 'New version is available!', message: 'New version is available!',
url: 'https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md', url: 'https://github.com/GeorgeSG/flame/blob/master/CHANGELOG.md',
}) });
); } else if (isForced) {
} else if (isForced) {
store.dispatch<any>(
createNotification({ createNotification({
title: 'Info', title: 'Info',
message: 'You are using the latest version!', message: 'You are using the latest version!',
}) });
); }
} catch (err) {
console.log(err);
} }
} catch (err) { };
console.log(err);
}
}; };

View file

@ -4,6 +4,10 @@
* @returns Parsed icon name to be used with mdi/js, e.g mdiAlertBoxOutline * @returns Parsed icon name to be used with mdi/js, e.g mdiAlertBoxOutline
*/ */
export const iconParser = (mdiName: string): string => { export const iconParser = (mdiName: string): string => {
if (mdiName.startsWith('mdi')) {
return mdiName;
}
let parsedName = mdiName let parsedName = mdiName
.split('-') .split('-')
.map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`) .map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
@ -11,4 +15,4 @@ export const iconParser = (mdiName: string): string => {
parsedName = `mdi${parsedName}`; parsedName = `mdi${parsedName}`;
return parsedName; return parsedName;
} };

View file

@ -13,4 +13,4 @@ export * from './decodeToken';
export * from './applyAuth'; export * from './applyAuth';
export * from './escapeRegex'; export * from './escapeRegex';
export * from './parseTheme'; export * from './parseTheme';
export * from './arrayPartition'; export * from './array';

View file

@ -1,68 +1,72 @@
import { queries } from './searchQueries.json'; import { useAtomValue } from 'jotai';
import { SearchResult } from '../interfaces';
import { store } from '../store/store';
import { isUrlOrIp } from '.'; 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 => { export const useSearchParser = () => {
const result: SearchResult = { const customQueries = useAtomValue(customQueriesAtom);
isLocal: false, const config = useAtomValue(configAtom);
isURL: false,
sameTab: false,
encodedURL: '',
primarySearch: {
name: '',
prefix: '',
template: '',
},
secondarySearch: {
name: '',
prefix: '',
template: '',
},
rawQuery: searchQuery,
};
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 // Check if url or ip was passed
result.isURL = isUrlOrIp(searchQuery); result.isURL = isUrlOrIp(searchQuery);
// Match prefix and query // Match prefix and query
const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i); const splitQuery = searchQuery.match(/^\/([a-z]+)(.+)$/i);
// Extract prefix // Extract prefix
const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider; const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
// Encode url // Encode url
const encodedURL = splitQuery const encodedURL = splitQuery
? encodeURIComponent(splitQuery[2]) ? encodeURIComponent(splitQuery[2])
: encodeURIComponent(searchQuery); : encodeURIComponent(searchQuery);
// Find primary search engine template // Find primary search engine template
const findProvider = (prefix: string) => { const findProvider = (prefix: string) => {
return [...queries, ...customQueries].find((q) => q.prefix === prefix); return [...queries, ...customQueries].find((q) => q.prefix === prefix);
}; };
const primarySearch = findProvider(prefix); const primarySearch = findProvider(prefix);
const secondarySearch = findProvider(config.secondarySearchProvider); const secondarySearch = findProvider(config.secondarySearchProvider);
// If search providers were found // If search providers were found
if (primarySearch) { if (primarySearch) {
result.primarySearch = primarySearch; result.primarySearch = primarySearch;
result.encodedURL = encodedURL; result.encodedURL = encodedURL;
if (prefix === 'l') { if (prefix === 'l') {
result.isLocal = true; result.isLocal = true;
} else { }
result.sameTab = config.searchSameTab; result.sameTab = config.searchSameTab;
}
if (secondarySearch) { if (secondarySearch) {
result.secondarySearch = secondarySearch; result.secondarySearch = secondarySearch;
}
return result;
} }
return result; return result;
} };
return result;
}; };

View file

@ -16,8 +16,10 @@ export const configTemplate: Config = {
hideApps: false, hideApps: false,
hideCategories: false, hideCategories: false,
hideSearch: false, hideSearch: false,
hideSearchProvider: false,
defaultSearchProvider: 'l', defaultSearchProvider: 'l',
secondarySearchProvider: 'd', secondarySearchProvider: 'd',
autoClearSearch: false,
dockerApps: false, dockerApps: false,
dockerHost: 'localhost', dockerHost: 'localhost',
kubernetesApps: false, kubernetesApps: false,

View file

@ -19,7 +19,9 @@ export const uiSettingsTemplate: UISettingsForm = {
showTime: false, showTime: false,
hideDate: false, hideDate: false,
hideSearch: false, hideSearch: false,
hideSearchProvider: false,
disableAutofocus: false, disableAutofocus: false,
autoClearSearch: false,
}; };
export const weatherSettingsTemplate: WeatherForm = { export const weatherSettingsTemplate: WeatherForm = {

View file

@ -11,62 +11,20 @@ const useDocker = async (apps) => {
dockerHost: host, dockerHost: host,
} = await loadConfig(); } = await loadConfig();
const dockerApps = [];
let containers = null; 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 // Get list of containers
try { try {
if (host.includes('localhost')) { if (host.includes('localhost')) {
// Use default host // Use default host
function getDocker(){ let { data } = await axios.get(
return axios.get( `http://${host}/containers/json?{"status":["running"]}`,
`http://${host}/containers/json?{"status":["running"]}`, {
{ socketPath: '/var/run/docker.sock',
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()
]);
containers = data;
} else { } else {
// Use custom host // Use custom host
let { data } = await axios.get( 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'); logger.log(`Can't connect to the Docker API on ${host}`, 'ERROR');
} }
if (containers_swarm) { if (containers) {
apps = await App.findAll({ apps = await App.findAll({
order: [[orderType, 'ASC']], order: [[orderType, 'ASC']],
}); });
services = containers_swarm.data; // Filter out containers without any annotations
for (const service of services) { containers = containers.filter((e) => Object.keys(e.Labels).length !== 0);
let labels = service.Spec.Labels;
labels['flame.name'] = service.Spec.Name; const dockerApps = [];
labels['flame.type'] = 'application';
for (const container of containers) {
let labels = container.Labels;
// Traefik labels for URL configuration
if (!('flame.url' in labels)) { if (!('flame.url' in labels)) {
for (const label of Object.keys(labels)) { for (const label of Object.keys(labels)) {
if (/^traefik.*.frontend.rule/.test(label)) { if (/^traefik.*.frontend.rule/.test(label)) {
@ -120,96 +81,68 @@ const useDocker = async (apps) => {
} }
} }
} }
addApp(dockerApps, labels);
}
}
if (containers) { // add each container as flame formatted app
apps = await App.findAll({ if (
order: [[orderType, 'ASC']], '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 if ('flame.icon' in labels) {
containers = containers.data.filter((e) => Object.keys(e.Labels).length !== 0); icons = labels['flame.icon'].split(';');
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(';');
}
}
}
} }
dockerApps.push({
name: names[i] || names[0],
url: urls[i] || urls[0],
icon: icons[i] || 'docker',
});
} }
} }
addApp(dockerApps, labels);
} }
}
if (unpinStoppedApps) { if (unpinStoppedApps) {
for (const app of apps) { for (const app of apps) {
await app.update({ isPinned: false }); 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 ( for (const item of dockerApps) {
item.icon === 'custom' || // If app already exists, update it
(item.icon === 'docker' && app.icon != 'docker') if (apps.some((app) => app.name === item.name)) {
) { const app = apps.find((a) => a.name === item.name);
// update without overriding icon
await app.update({ if (
name: item.name, item.icon === 'custom' ||
url: item.url, (item.icon === 'docker' && app.icon != 'docker')
isPinned: true, ) {
}); // update without overriding icon
await app.update({
name: item.name,
url: item.url,
isPinned: true,
});
} else {
await app.update({
...item,
isPinned: true,
});
}
} else { } else {
await app.update({ // else create new app
await App.create({
...item, ...item,
icon: item.icon === 'custom' ? 'docker' : item.icon,
isPinned: true, isPinned: true,
}); });
} }
} else {
// else create new app
await App.create({
...item,
icon: item.icon === 'custom' ? 'docker' : item.icon,
isPinned: true,
});
} }
} }
}; };
module.exports = useDocker; module.exports = useDocker;

View file

@ -35,14 +35,14 @@ const useKubernetes = async (apps) => {
const annotations = ingress.metadata.annotations; const annotations = ingress.metadata.annotations;
if ( if (
'flame.pawelmalak/name' in annotations && 'flame.georgesg/name' in annotations &&
'flame.pawelmalak/url' in annotations && 'flame.georgesg/url' in annotations &&
/^app/.test(annotations['flame.pawelmalak/type']) /^app/.test(annotations['flame.georgesg/type'])
) { ) {
kubernetesApps.push({ kubernetesApps.push({
name: annotations['flame.pawelmalak/name'], name: annotations['flame.georgesg/name'],
url: annotations['flame.pawelmalak/url'], url: annotations['flame.georgesg/url'],
icon: annotations['flame.pawelmalak/icon'] || 'kubernetes', icon: annotations['flame.georgesg/icon'] || 'kubernetes',
}); });
} }
} }

View file

@ -6,10 +6,10 @@ metadata:
annotations: annotations:
kubernetes.io/ingress.class: nginx kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: ca-cluster-issuer cert-manager.io/cluster-issuer: ca-cluster-issuer
flame.pawelmalak/name: flame flame.georgesg/name: flame
flame.pawelmalak/url: dev.flame.shokohsc.home flame.georgesg/url: dev.flame.shokohsc.home
flame.pawelmalak/type: app flame.georgesg/type: app
flame.pawelmalak/icon: fire flame.georgesg/icon: fire
spec: spec:
rules: rules:
- host: dev.flame.shokohsc.home - host: dev.flame.shokohsc.home

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "flame", "name": "flame",
"version": "0.1.0", "version": "2.4.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flame", "name": "flame",
"version": "0.1.0", "version": "2.4.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@kubernetes/client-node": "^0.15.1", "@kubernetes/client-node": "^0.15.1",

View file

@ -1,6 +1,6 @@
{ {
"name": "flame", "name": "flame",
"version": "0.1.0", "version": "2.4.0",
"description": "Self-hosted start page", "description": "Self-hosted start page",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View file

@ -3,27 +3,31 @@ const axios = require('axios');
const loadConfig = require('./loadConfig'); const loadConfig = require('./loadConfig');
const getExternalWeather = async () => { 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 // Fetch data from external API
try { try {
const res = await axios.get( 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 // 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({ const weatherData = await Weather.create({
externalLastUpdate: cursor.last_updated, externalLastUpdate: cursor.dt,
tempC: cursor.temp_c, tempC: cursor.main.temp,
tempF: cursor.temp_f, tempF: cursor.main.temp,
isDay: cursor.is_day, isDay: isDay,
cloud: cursor.cloud, cloud: cursor.clouds.all,
conditionText: cursor.condition.text, conditionText: cursor.weather[0].main,
conditionCode: cursor.condition.code, conditionCode: cursor.weather[0].id,
humidity: cursor.humidity, humidity: cursor.main.humidity,
windK: cursor.wind_kph, windK: cursor.wind.speed,
windM: cursor.wind_mph, windM: 0,
}); });
return weatherData; return weatherData;
} catch (err) { } catch (err) {

View file

@ -14,6 +14,7 @@
"hideApps": false, "hideApps": false,
"hideCategories": false, "hideCategories": false,
"hideSearch": false, "hideSearch": false,
"hideSearchProvider": false,
"defaultSearchProvider": "l", "defaultSearchProvider": "l",
"secondarySearchProvider": "d", "secondarySearchProvider": "d",
"dockerApps": false, "dockerApps": false,