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)
- 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))

View file

@ -2,6 +2,16 @@
![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
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary.
@ -19,23 +29,23 @@ Flame is self-hosted startpage for your server. Its design is inspired (heavily)
### With Docker (recommended)
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
[Docker Hub link](https://hub.docker.com/r/mhzawadi/flame)
```sh
docker pull pawelmalak/flame
docker pull mhzawadi/flame
# for ARM architecture (e.g. RaspberryPi)
docker pull pawelmalak/flame:multiarch
docker pull mhzawadi/flame:multiarch
# installing specific version
docker pull pawelmalak/flame:2.0.0
docker pull mhzawadi/flame:2.0.0
```
#### Deployment
```sh
# run container
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password pawelmalak/flame
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password mhzawadi/flame
```
#### Building images
@ -59,7 +69,7 @@ version: '3.6'
services:
flame:
image: pawelmalak/flame
image: mhzawadi/flame
container_name: flame
volumes:
- /path/to/host/data:/app/data
@ -123,7 +133,7 @@ Follow instructions from wiki: [Installation without Docker](https://github.com/
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
git clone https://github.com/mhzawadi/flame
cd flame
# run only once
@ -219,10 +229,10 @@ In order to use the Kubernetes integration, each ingress must have the following
```yml
metadata:
annotations:
- flame.pawelmalak/type=application # "app" works too
- flame.pawelmalak/name=My container
- flame.pawelmalak/url=https://example.com
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
- flame.georgesg/type=application # "app" works too
- flame.georgesg/name=My container
- flame.georgesg/url=https://example.com
- flame.georgesg/icon=icon-name # optional, default is "kubernetes"
```
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker

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",
"version": "0.1.0",
"version": "2.4.0",
"private": true,
"dependencies": {
"@mdi/js": "^6.4.95",
"@mdi/react": "^1.5.0",
"axios": "^0.24.0",
"classnames": "^2.3.2",
"external-svg-loader": "^1.3.4",
"http-proxy-middleware": "^2.0.1",
"jotai": "^1.10.0",
"jwt-decode": "^3.1.2",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"skycons-ts": "^0.2.0",
"web-vitals": "^2.1.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.15.0",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
@ -13,24 +28,9 @@
"@types/react": "^17.0.34",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.11",
"@types/react-redux": "^7.1.20",
"@types/react-router-dom": "^5.1.7",
"axios": "^0.24.0",
"external-svg-loader": "^1.3.4",
"http-proxy-middleware": "^2.0.1",
"jwt-decode": "^3.1.2",
"react": "^17.0.2",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"redux": "^4.1.2",
"redux-devtools-extension": "^2.13.9",
"redux-thunk": "^2.4.0",
"skycons-ts": "^0.2.0",
"typescript": "^4.4.4",
"web-vitals": "^2.1.2"
"prettier": "^2.4.1",
"typescript": "^4.4.4"
},
"scripts": {
"start": "react-scripts start",
@ -55,8 +55,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"prettier": "^2.4.1"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,33 +1,21 @@
import { NavLink, Link, Switch, Route } from 'react-router-dom';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../store/reducers';
// Typescript
import { useAtomValue } from 'jotai';
import { Link, NavLink, Route, Switch } from 'react-router-dom';
import { Route as SettingsRoute } from '../../interfaces';
// CSS
import classes from './Settings.module.css';
// Components
import { Themer } from './Themer/Themer';
import { WeatherSettings } from './WeatherSettings/WeatherSettings';
import { UISettings } from './UISettings/UISettings';
import { AppDetails } from './AppDetails/AppDetails';
import { StyleSettings } from './StyleSettings/StyleSettings';
import { GeneralSettings } from './GeneralSettings/GeneralSettings';
import { DockerSettings } from './DockerSettings/DockerSettings';
import { authAtom } from '../../state/auth';
import { ProtectedRoute } from '../Routing/ProtectedRoute';
// UI
import { Container, Headline } from '../UI';
// Data
import { AppDetails } from './AppDetails/AppDetails';
import { DockerSettings } from './DockerSettings/DockerSettings';
import { GeneralSettings } from './GeneralSettings/GeneralSettings';
import { routes } from './settings.json';
import classes from './Settings.module.css';
import { StyleSettings } from './StyleSettings/StyleSettings';
import { Themer } from './Themer/Themer';
import { UISettings } from './UISettings/UISettings';
import { WeatherSettings } from './WeatherSettings/WeatherSettings';
export const Settings = (): JSX.Element => {
const { isAuthenticated } = useSelector((state: State) => state.auth);
const { isAuthenticated } = useAtomValue(authAtom);
const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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;
}
.InputGroup.Horizontal {
display: flex;
align-items: center;
gap: 8px;
}
.InputGroup label {
cursor: pointer;
}
.InputGroup label,
.InputGroup span,
.InputGroup input,

View file

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

View file

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

View file

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

View file

@ -4,4 +4,9 @@
font-weight: 900;
font-size: 20px;
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 classes from './SectionHeadline.module.css';
interface Props {
@ -9,7 +8,7 @@ interface Props {
export const SectionHeadline = (props: Props): JSX.Element => {
return (
<Link to={props.link}>
<Link to={props.link} className={classes.SectionHeadlineLink}>
<h2 className={classes.SectionHeadline}>{props.title}</h2>
</Link>
);

View file

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

View file

@ -2,3 +2,14 @@
color: var(--color-primary);
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 { iconParser } from '../../../../utility';
import classes from './Icon.module.css';
interface Props {
icon: string;
color?: string;
}
export const Icon = (props: Props): JSX.Element => {
export const Icon = ({ icon, color }: Props): JSX.Element => {
const MDIcons = require('@mdi/js');
let iconPath = MDIcons[props.icon];
const mdiIcon = iconParser(icon);
let mdiIconPath = MDIcons[mdiIcon];
if (!iconPath) {
console.log(`Icon ${props.icon} not found`);
iconPath = MDIcons.mdiCancel;
if (!mdiIconPath) {
return (
<div className={classes.AppIconWrapper}>
<img
src={`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}.png`}
className={classes.AppIcon}
alt={icon}
/>
</div>
);
} else {
return (
<MDIcon
className={classes.Icon}
path={mdiIconPath}
color={color ? color : 'var(--color-primary)'}
/>
);
}
return (
<MDIcon
className={classes.Icon}
path={iconPath}
color={props.color ? props.color : 'var(--color-primary)'}
/>
);
};

View file

@ -13,345 +13,10 @@ export enum TimeOfDay {
night
}
const mapFromJson = require('./WeatherMapping.json')
export class IconMapping {
private conditions: WeatherCondition[] = [
{
code: 1000,
icon: {
day: 'clear-day',
night: 'clear-night'
}
},
{
code: 1003,
icon: {
day: 'partly-cloudy-day',
night: 'partly-cloudy-night'
}
},
{
code: 1006,
icon: {
day: 'cloudy',
night: 'cloudy'
}
},
{
code: 1009,
icon: {
day: 'cloudy',
night: 'cloudy'
}
},
{
code: 1030,
icon: {
day: 'fog',
night: 'fog'
}
},
{
code: 1063,
icon: {
day: 'rain-day',
night: 'rain-night'
}
},
{
code: 1066,
icon: {
day: 'snow-day',
night: 'snow-night'
}
},
{
code: 1069,
icon: {
day: 'rain-snow-day',
night: 'rain-snow-night'
}
},
{
code: 1072,
icon: {
day: 'sleet',
night: 'sleet'
}
},
{
code: 1087,
icon: {
day: 'thunder-day',
night: 'thunder-night'
}
},
{
code: 1114,
icon: {
day: 'snow',
night: 'snow'
}
},
{
code: 1117,
icon: {
day: 'snow',
night: 'snow'
}
},
{
code: 1135,
icon: {
day: 'fog',
night: 'fog'
}
},
{
code: 1147,
icon: {
day: 'fog',
night: 'fog'
}
},
{
code: 1150,
icon: {
day: 'rain',
night: 'rain'
}
},
{
code: 1153,
icon: {
day: 'rain',
night: 'rain'
}
},
{
code: 1168,
icon: {
day: 'sleet',
night: 'sleet'
}
},
{
code: 1171,
icon: {
day: 'sleet',
night: 'sleet'
}
},
{
code: 1180,
icon: {
day: 'rain-day',
night: 'rain-night'
}
},
{
code: 1183,
icon: {
day: 'rain',
night: 'rain'
}
},
{
code: 1186,
icon: {
day: 'rain-day',
night: 'rain-night'
}
},
{
code: 1189,
icon: {
day: 'rain',
night: 'rain'
}
},
{
code: 1192,
icon: {
day: 'rain-day',
night: 'rain-night'
}
},
{
code: 1195,
icon: {
day: 'rain',
night: 'rain'
}
},
{
code: 1198,
icon: {
day: 'sleet',
night: 'sleet'
}
},
{
code: 1201,
icon: {
day: 'sleet',
night: 'sleet'
}
},
{
code: 1204,
icon: {
day: 'rain-snow',
night: 'rain-snow'
}
},
{
code: 1207,
icon: {
day: 'rain-snow',
night: 'rain-snow'
}
},
{
code: 1210,
icon: {
day: 'snow-day',
night: 'snow-night'
}
},
{
code: 1213,
icon: {
day: 'snow',
night: 'snow'
}
},
{
code: 1216,
icon: {
day: 'snow-day',
night: 'snow-night'
}
},
{
code: 1219,
icon: {
day: 'snow',
night: 'snow'
}
},
{
code: 1222,
icon: {
day: 'snow-day',
night: 'snow-night'
}
},
{
code: 1225,
icon: {
day: 'snow',
night: 'snow'
}
},
{
code: 1237,
icon: {
day: 'hail',
night: 'hail'
}
},
{
code: 1240,
icon: {
day: 'rain-day',
night: 'rain-night'
}
},
{
code: 1243,
icon: {
day: 'rain-day',
night: 'rain-night'
}
},
{
code: 1246,
icon: {
day: 'rain-day',
night: 'rain-night'
}
},
{
code: 1249,
icon: {
day: 'rain-snow-day',
night: 'rain-snow-night'
}
},
{
code: 1252,
icon: {
day: 'rain-snow-day',
night: 'rain-snow-night'
}
},
{
code: 1255,
icon: {
day: 'snow-day',
night: 'snow-night'
}
},
{
code: 1258,
icon: {
day: 'snow-day',
night: 'snow-night'
}
},
{
code: 1261,
icon: {
day: 'hail',
night: 'hail'
}
},
{
code: 1264,
icon: {
day: 'hail',
night: 'hail'
}
},
{
code: 1273,
icon: {
day: 'thunder-rain-day',
night: 'thunder-rain-night'
}
},
{
code: 1276,
icon: {
day: 'thunder-rain',
night: 'thunder-rain'
}
},
{
code: 1279,
icon: {
day: 'thunder-day',
night: 'thunder-night'
}
},
{
code: 1282,
icon: {
day: 'thunder',
night: 'thunder'
}
}
];
private conditions: WeatherCondition[] = mapFromJson.mapping
mapIcon(weatherStatusCode: number, timeOfDay: TimeOfDay): IconKey {
const mapping = this.conditions.find((condition: WeatherCondition) => condition.code === weatherStatusCode);

View file

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

View file

@ -1,340 +1,382 @@
{
"mapping": [
{
"code": 1000,
"code": 800,
"icon": {
"day": "clear-day",
"night": "clear-night"
}
},
{
"code": 1003,
"code": 801,
"icon": {
"day": "partly-cloudy-day",
"night": "partly-cloudy-night"
}
},
{
"code": 1006,
"code": 802,
"icon": {
"day": "partly-cloudy-day",
"night": "partly-cloudy-night"
}
},
{
"code": 803,
"icon": {
"day": "cloudy",
"night": "cloudy"
}
},
{
"code": 1009,
"code": 804,
"icon": {
"day": "cloudy",
"night": "cloudy"
}
},
{
"code": 1030,
"code": 701,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 1063,
"code": 711,
"icon": {
"day": "rain-day",
"night": "rain-night"
"day": "fog",
"night": "fog"
}
},
{
"code": 1066,
"code": 721,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 731,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 741,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 751,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 761,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 771,
"icon": {
"day": "wind",
"night": "wind"
}
},
{
"code": 781,
"icon": {
"day": "wind",
"night": "wind"
}
},
{
"code": 600,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 1069,
"code": 601,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 602,
"icon": {
"day": "snow",
"night": "snow"
}
},
{
"code": 611,
"icon": {
"day": "sleet",
"night": "sleet"
}
},
{
"code": 612,
"icon": {
"day": "sleet",
"night": "sleet"
}
},
{
"code": 613,
"icon": {
"day": "sleet",
"night": "sleet"
}
},
{
"code": 615,
"icon": {
"day": "rain-snow-day",
"night": "rain-snow-night"
}
},
{
"code": 1072,
"code": 616,
"icon": {
"day": "sleet",
"night": "sleet"
"day": "rain-snow-day",
"night": "rain-snow-night"
}
},
{
"code": 1087,
"icon": {
"day": "thunder-day",
"night": "thunder-night"
}
},
{
"code": 1114,
"icon": {
"day": "snow",
"night": "snow"
}
},
{
"code": 1117,
"icon": {
"day": "snow",
"night": "snow"
}
},
{
"code": 1135,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 1147,
"icon": {
"day": "fog",
"night": "fog"
}
},
{
"code": 1150,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 1153,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 1168,
"icon": {
"day": "sleet",
"night": "sleet"
}
},
{
"code": 1171,
"icon": {
"day": "sleet",
"night": "sleet"
}
},
{
"code": 1180,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 1183,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 1186,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 1189,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 1192,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 1195,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 1198,
"icon": {
"day": "sleet",
"night": "sleet"
}
},
{
"code": 1201,
"icon": {
"day": "sleet",
"night": "sleet"
}
},
{
"code": 1204,
"code": 620,
"icon": {
"day": "rain-snow",
"night": "rain-snow"
}
},
{
"code": 1207,
"code": 621,
"icon": {
"day": "rain-snow",
"night": "rain-snow"
}
},
{
"code": 1210,
"code": 622,
"icon": {
"day": "snow-day",
"night": "snow-night"
"day": "rain-snow",
"night": "rain-snow"
}
},
{
"code": 1213,
"icon": {
"day": "snow",
"night": "snow"
}
},
{
"code": 1216,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 1219,
"icon": {
"day": "snow",
"night": "snow"
}
},
{
"code": 1222,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 1225,
"icon": {
"day": "snow",
"night": "snow"
}
},
{
"code": 1237,
"icon": {
"day": "hail",
"night": "hail"
}
},
{
"code": 1240,
"code": 500,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 1243,
"code": 501,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 1246,
"code": 502,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 1249,
"code": 503,
"icon": {
"day": "rain-snow-day",
"night": "rain-snow-night"
"day": "rain",
"night": "rain"
}
},
{
"code": 1252,
"code": 504,
"icon": {
"day": "rain-snow-day",
"night": "rain-snow-night"
"day": "rain",
"night": "rain"
}
},
{
"code": 1255,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 1258,
"icon": {
"day": "snow-day",
"night": "snow-night"
}
},
{
"code": 1261,
"code": 511,
"icon": {
"day": "hail",
"night": "hail"
}
},
{
"code": 1264,
"code": 520,
"icon": {
"day": "hail",
"night": "hail"
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 1273,
"code": 521,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 522,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 531,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 300,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 301,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 302,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 310,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 311,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 312,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 313,
"icon": {
"day": "rain-day",
"night": "rain-night"
}
},
{
"code": 314,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 321,
"icon": {
"day": "rain",
"night": "rain"
}
},
{
"code": 200,
"icon": {
"day": "thunder-rain-day",
"night": "thunder-rain-night"
}
},
{
"code": 1276,
"code": 201,
"icon": {
"day": "thunder-rain",
"night": "thunder-rain"
"day": "thunder-rain-day",
"night": "thunder-rain-night"
}
},
{
"code": 1279,
"code": 202,
"icon": {
"day": "thunder-rain-day",
"night": "thunder-rain-night"
}
},
{
"code": 210,
"icon": {
"day": "thunder-day",
"night": "thunder-night"
}
},
{
"code": 1282,
"code": 211,
"icon": {
"day": "thunder-day",
"night": "thunder-night"
}
},
{
"code": 212,
"icon": {
"day": "thunder",
"night": "thunder"
}
},
{
"code": 221,
"icon": {
"day": "thunder-day",
"night": "thunder-night"
}
},
{
"code": 230,
"icon": {
"day": "thunder-rain-day",
"night": "thunder-rain-night"
}
},
{
"code": 231,
"icon": {
"day": "thunder-rain-day",
"night": "thunder-rain-night"
}
},
{
"code": 232,
"icon": {
"day": "thunder-rain-day",
"night": "thunder-rain-night"
}
}
]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

4
package-lock.json generated
View file

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

View file

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

View file

@ -3,27 +3,31 @@ const axios = require('axios');
const loadConfig = require('./loadConfig');
const getExternalWeather = async () => {
const { WEATHER_API_KEY: secret, lat, long } = await loadConfig();
const { WEATHER_API_KEY: secret, lat, long, isCelsius } = await loadConfig();
//units = standard, metric, imperial
const units = isCelsius?'metric':'imperial'
// Fetch data from external API
try {
const res = await axios.get(
`http://api.weatherapi.com/v1/current.json?key=${secret}&q=${lat},${long}`
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${long}&appid=${secret}&units=${units}`
);
// Save weather data
const cursor = res.data.current;
const cursor = res.data;
const isDay = (Math.floor(Date.now()/1000) < cursor.sys.sunset) | 0
const weatherData = await Weather.create({
externalLastUpdate: cursor.last_updated,
tempC: cursor.temp_c,
tempF: cursor.temp_f,
isDay: cursor.is_day,
cloud: cursor.cloud,
conditionText: cursor.condition.text,
conditionCode: cursor.condition.code,
humidity: cursor.humidity,
windK: cursor.wind_kph,
windM: cursor.wind_mph,
externalLastUpdate: cursor.dt,
tempC: cursor.main.temp,
tempF: cursor.main.temp,
isDay: isDay,
cloud: cursor.clouds.all,
conditionText: cursor.weather[0].main,
conditionCode: cursor.weather[0].id,
humidity: cursor.main.humidity,
windK: cursor.wind.speed,
windM: 0,
});
return weatherData;
} catch (err) {

View file

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