Merge pull request #12 from phntxx/refactor

Refactor
This commit is contained in:
Bastian Meissner 2021-03-21 20:20:57 +01:00 committed by GitHub
commit b5c0e400ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 693 additions and 657 deletions

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 Bastian Meissner
Copyright (c) 2021 Bastian Meissner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

295
README.md
View file

@ -1,33 +1,61 @@
# Dashboard
## IMPORTANT: UPDATE
Yesterday, an update has been released that changed a couple of things:
- The serving port has been changed from `3000` to `8080`.
- The structure of `imprint.json` has been changed. Make sure that the format of your `imprint.json`-file matches the format of the ones within this repository.
![Alt text](/screenshot.png?raw=true "screenshot")
Dashboard is just that - a dashboard. It's inspired by [SUI](https://github.com/jeroenpardon/sui) and has all the same features as SUI, such as simple customization through JSON-files and a handy search bar to search the internet more efficiently.
## Features
So what makes this thing better than SUI?
So what makes this project different from (or even better than) SUI?
- "Display URL" functionality, in case the URL you want to show is different than the URL you want to be redirected to
- Theming through JSON
- Search providers customizable through JSON (SUI has them both in a JSON and hardcoded)
- "Display URL" functionality (The URL displayed for apps can differ from the actual URL)
- Categorization for apps
- Themes and search providers can be changed using JSON
- Imprint functionality
## Installation
Getting Dashboard to run is fairly simple and can be accomplished with two techniques:
The recommended way of installation is using [Docker](https://docker.com). You could also build your own version from source, but do proceed at your own risk.
1. Locally
### Docker
Prerequisites: yarn, npm, node
The Docker image is built on top of [this image](https://github.com/ratisbona-coding/nginx-cloudflare-cache), as it's based on Nginx and also provides functionality to purge the Cloudflare cache every time the container restarts (though this functionality is entirely optional).
1. Using the Docker CLI:
```sh
$ docker run -d \
-e CLOUDFLARE_ZONE_ID=[OPTIONAL CLOUDFLARE V4 ZONE ID] \
-e CLOUDFLARE_PURGE_TOKEN=[OPTIONAL CLOUDFLARE PURGE TOKEN] \
-v $(pwd)/data:/app/data
-p 8080:8080 \
--name dashboard \
phntxx/dashboard
```
2. Using Docker-Compose:
```yml
version: "3"
services:
dashboard:
image: phntxx/dashboard:latest
restart: unless-stopped
environment:
- CLOUDFLARE_ZONE_ID=[OPTIONAL CLOUDFLARE V4 ZONE ID]
- CLOUDFLARE_PURGE_TOKEN=[OPTIONAL CLOUDFLARE PURGE TOKEN]
volumes:
- [path to data directory]:/app/data
ports:
- 8080:8080
```
### Compile from source
I really don't anticipate people to use this, so go forth at your own risk.
```bash
$ git clone https://github.com/phntxx/dashboard.git
$ cd dashboard/
$ yarn
@ -35,196 +63,139 @@ $ yarn build
$ yarn serve:production
```
2. Using Docker
## Configuration
```
$ docker run -d \
-v $(pwd)/data:/app/data
-p 8080:8080 \
--name dashboard \
phntxx/dashboard
```
There's a couple of things you can / need to configure to get Dashboard to look and behave just as you want it to.
Sample Docker Compose configuration:
If you don't require a specific component, just remove the file from your `data`-directory. Dashboard won't render the components whose files are not present. With no files present, only the greeter will be shown.
```
version: "3"
If you're running into problems with configuring your files and you can't seem to get them to work, feel free to open an issue, I'd be happy to help! :smile:
services:
dashboard:
image: phntxx/dashboard:latest
restart: unless-stopped
volumes:
- [path to data directory]:/app/data
ports:
- 8080:8080
```
### Apps
**Note: You might still need to clone the repository in order to get the JSON-files which are required for the
app to run**
To show the apps you want to show, change `apps.json` to resemble the following:
## Customization
Dashboard is designed to be customizable. Everything is handled using four .json-files, which can be found at /src/components/data
### Applications
To add an application, append the following to apps.json or simply edit one of the examples given.
```
```json
{
"name": "[Name of the Application]",
"categories": [
{
"name": "[Name of the category]",
"items": [
{
"name": "[Name of the app]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
}
},
...
]
},
...
],
"apps": [
{
"name": "[Name of the app]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
},
...
]
}
```
Wherein either `apps` or `categories` can be omitted as needed.
To find icons, simply go to the [Material Design Icon Library](https://material.io/icons/) and copy one of the codes for an icon there.
**NEW FEATURE: CATEGORIES**
To add a category to your dashboard, change apps.json to resemble the following:
```
{
"categories": [
...
],
"apps": [
...
]
}
```
Then, a category can be added by entering the following within the "categories" field:
```
{
"name": "[Name of the category]",
"items": [
[Application goes here]
]
}
```
In the end, your apps.json file should look something like this:
1. Without categories
```
{
"apps": [
{
"name": "[Name of the Application]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
},
{
"name": "[Name of the Application]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
},
...
]
}
```
2. With apps and categories
```
{
"categories": [
{
"name": "[Name of the category]",
"items": [
{
"name": "[Name of the Application]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
},
{
"name": "[Name of the Application]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
},
...
]
},
...
],
"apps": [
{
"name": "[Name of the Application]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
},
{
"name": "[Name of the Application]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
},
...
]
}
```
### Bookmarks
To add a bookmark, append the following to bookmarks.json or simply edit one of the examples given.
To show bookmarks, `bookmarks.json` needs to resemble the following:
```
```json
{
"name": "[Category name]",
"groups": [
{
"name": "[Group Name]",
"items": [
{
"name": "[Bookmark name]",
"url": "[URL to redirect to]"
"name": "[Bookmark Name]",
"url": "[Bookmark URL]"
},
{
"name": "[Bookmark name]",
"url": "[URL to redirect to]"
},
{
"name": "[Bookmark name]",
"url": "[URL to redirect to]"
}
...
]
},
...
]
}
```
### Theming:
### Themes
Dashboard also supports themes with the help of a simple JSON-file: themes.json. To add a theme, append the following to themes.json:
In order to customize theming, `themes.json` needs to resemble this:
```
```json
{
"themes": [
{
"label": "[Theme Name]",
"value": [Number of the theme],
"value": "[Number of the theme]",
"mainColor": "[Main Color as 6-character hex code]",
"accentColor": "[Accent Color as 6-character hex code]",
"backgroundColor": "[Background Color as 6-character hex code]"
},
...
]
}
```
### Search Providers:
### Search Providers
The searchbar on the top supports shortcuts like "/so", just as SUI does. To add one of your own, simply append the following to search.json
For search providers to work, make sure your `search.json` resembles the following:
```
```json
{
"providers": [
{
"name": "[Name of the website]",
"url": "[Link that processes searches on that website]",
"prefix":"[a custom prefix]"
"prefix": "[A custom prefix (e.g. '/test')]"
},
...
]
}
```
### Imprint
In order for the imprint-modal to show up, make sure your `imprint.json` resembles the following:
```json
{
"imprint": {
"name": {
"text": "[Name]",
"link": "[Link to the name (to e.g. a portfolio)]"
},
"address": {
"text": "[Address]",
"link": "[Link for the address (to e.g. Google Maps)]"
},
"phone": {
"text": "[Phone number]",
"link": "[Link for the phone number]"
},
"email": {
"text": "[Email address]",
"link": "[Link for the email address (e.g. for 'mailto')]"
},
"url": {
"text": "[URL]",
"link": "[Link for the URL]"
},
"text": "[Text for the imprint]"
}
}
```
> :exclamation: I haven't quite tested this. I'm not a lawyer and I'm not responsible if you're sued for using this incorrectly.

View file

@ -19,6 +19,7 @@
"url": {
"text": "example.com",
"link": "#"
}
},
"text": "This is the place where you should put whatever you want it to say on the imprint page."
}
}

View file

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React from "react";
import { createGlobalStyle } from "styled-components";
import SearchBar from "./components/searchBar";
@ -8,14 +8,8 @@ import BookmarkList from "./components/bookmarkList";
import Settings from "./components/settings";
import Imprint from "./components/imprint";
import selectedTheme from "./components/themeManager";
import {
useAppData,
useSearchProviderData,
useBookmarkData,
useThemeData,
useImprintData,
} from "./components/fetch";
import selectedTheme from "./lib/theme";
import useFetcher from "./lib/fetcher";
const GlobalStyle = createGlobalStyle`
body {
@ -32,12 +26,12 @@ const GlobalStyle = createGlobalStyle`
}
`;
/**
* Renders the entire app by calling individual components
*/
const App = () => {
const { appData } = useAppData();
const { searchProviderData } = useSearchProviderData();
const { bookmarkData } = useBookmarkData();
const { themeData } = useThemeData();
const { imprintData } = useImprintData();
const { appData, bookmarkData, searchProviderData, themeData, imprintData } = useFetcher();
return (
<>

View file

@ -1,7 +1,7 @@
import React from "react";
import Icon from "./icon";
import styled from "styled-components";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
const AppContainer = styled.div`
display: flex;
@ -46,17 +46,21 @@ const AppDescription = styled.p`
export interface IAppProps {
name: string;
icon: string;
URL: string;
url: string;
displayURL: string;
}
export const App = ({ name, icon, URL, displayURL }: IAppProps) => (
/**
* Renders a single app shortcut
* @param {IAppProps} props - The props of the given app
*/
export const App = ({ name, icon, url, displayURL }: IAppProps) => (
<AppContainer>
<IconContainer>
<Icon name={icon} />
</IconContainer>
<DetailsContainer>
<AppLink href={URL}>{name}</AppLink>
<AppLink href={url}>{name}</AppLink>
<AppDescription>{displayURL}</AppDescription>
</DetailsContainer>
</AppContainer>

View file

@ -16,6 +16,10 @@ export interface IAppCategoryProps {
items: Array<IAppProps>;
}
/**
* Renders one app category
* @param {IAppCategoryProps} props - The props of the given category
*/
export const AppCategory = ({ name, items }: IAppCategoryProps) => (
<CategoryContainer>
{name && <CategoryHeadline>{name}</CategoryHeadline>}
@ -25,7 +29,7 @@ export const AppCategory = ({ name, items }: IAppCategoryProps) => (
<App
name={app.name}
icon={app.icon}
URL={app.URL}
url={app.url}
displayURL={app.displayURL}
/>
</Item>

View file

@ -9,8 +9,11 @@ export interface IAppListProps {
apps: Array<IAppProps>;
}
const AppList = ({ categories, apps }: IAppListProps) => {
return (
/**
* Renders one list containing all app categories and uncategorized apps
* @param {IAppListProps} props - The props of the given list of apps
*/
const AppList = ({ categories, apps }: IAppListProps) => (
<ListContainer>
<Headline>Applications</Headline>
{categories &&
@ -25,6 +28,5 @@ const AppList = ({ categories, apps }: IAppListProps) => {
)}
</ListContainer>
);
};
export default AppList;

View file

@ -1,7 +1,7 @@
import React from "react";
import styled from "styled-components";
import { Item, SubHeadline } from "./elements";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
const GroupContainer = styled.div`
display: flex;
@ -32,13 +32,14 @@ export interface IBookmarkGroupProps {
items: Array<IBookmarkProps>;
}
export const BookmarkGroup = ({
name: groupName,
items,
}: IBookmarkGroupProps) => (
/**
* Renders a given bookmark group
* @param {IBookmarkGroupProps} props - The given props of the bookmark group
*/
export const BookmarkGroup = ({ name, items }: IBookmarkGroupProps) => (
<Item>
<GroupContainer>
<SubHeadline>{groupName}</SubHeadline>
<SubHeadline>{name}</SubHeadline>
{items.map(({ name, url }, idx) => (
<Bookmark key={[name, idx].join("")} href={url}>
{name}

View file

@ -1,15 +1,16 @@
import React from "react";
import { Headline, ListContainer, ItemList } from "./elements";
import { BookmarkGroup, IBookmarkGroupProps } from "./bookmarkGroup";
interface IBookmarkListProps {
groups: Array<IBookmarkGroupProps>;
}
const BookmarkList = ({ groups }: IBookmarkListProps) => {
return (
/**
* Renders a given list of categorized bookmarks
* @param {IBookmarkListProps} props - The props of the given bookmark list
*/
const BookmarkList = ({ groups }: IBookmarkListProps) => (
<ListContainer>
<Headline>Bookmarks</Headline>
<ItemList>
@ -19,6 +20,5 @@ const BookmarkList = ({ groups }: IBookmarkListProps) => {
</ItemList>
</ListContainer>
);
};
export default BookmarkList;

View file

@ -1,10 +1,8 @@
import React from "react";
import styled from "styled-components";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
import Icon from "./icon";
// File for elements that are/can be reused across the entire site.
export const ListContainer = styled.div`
padding: 2rem 0;
`;
@ -50,7 +48,8 @@ export const Button = styled.button`
border: 1px solid ${selectedTheme.mainColor};
color: ${selectedTheme.mainColor};
background: none;
min-height: 3em;
min-height: 2rem;
height: 100%;
&:hover {
@ -61,6 +60,7 @@ export const Button = styled.button`
const StyledButton = styled.button`
float: right;
border: none;
padding: 0;
background: none;
&:hover {
@ -68,21 +68,15 @@ const StyledButton = styled.button`
}
`;
export const RefreshButton = styled(Button)`
display: relative;
top: 0;
float: right;
`;
export const ErrorMessage = styled.p`
color: red;
`;
interface IIconButtonProps {
icon: string;
onClick: any;
}
/**
* Renders a button with an icon
* @param {IIconProps} props - The props of the given IconButton
*/
export const IconButton = ({ icon, onClick }: IIconButtonProps) => (
<StyledButton onClick={onClick}>
<Icon name={icon} />

View file

@ -1,193 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { ISearchProviderProps } from "./searchBar";
import { IBookmarkGroupProps } from "./bookmarkGroup";
import { IAppCategoryProps } from "./appCategory";
import { IAppProps } from "./app";
import { IThemeProps } from "./themeManager";
import { IImprintProps } from "./imprint";
const errorMessage = "Failed to load data.";
const handleResponse = (response: any) => {
if (response.ok) return response.json();
throw new Error(errorMessage);
};
// SECTION: Search Provider
export interface ISearchProviderDataProps {
providers: Array<ISearchProviderProps>;
error: string | boolean;
}
export const useSearchProviderData = () => {
const [
searchProviderData,
setSearchProviderData,
] = useState<ISearchProviderDataProps>({ providers: [], error: false });
const fetchSearchProviderData = useCallback(() => {
(process.env.NODE_ENV === "production"
? fetch("/data/search.json").then(handleResponse)
: import("./data/search.json")
)
.then((jsonResponse) => {
setSearchProviderData({ ...jsonResponse, error: false });
})
.catch((error) => {
setSearchProviderData({ providers: [], error: error.message });
});
}, []);
useEffect(() => {
fetchSearchProviderData();
}, [fetchSearchProviderData]);
return { searchProviderData, fetchSearchProviderData };
};
// SECTION: Bookmark data
export interface IBookmarkDataProps {
groups: Array<IBookmarkGroupProps>;
error: string | boolean;
}
export const useBookmarkData = () => {
const [bookmarkData, setBookmarkData] = useState<IBookmarkDataProps>({
groups: [],
error: false,
});
const fetchBookmarkData = useCallback(() => {
(process.env.NODE_ENV === "production"
? fetch("/data/bookmarks.json").then(handleResponse)
: import("./data/bookmarks.json")
)
.then((jsonResponse) => {
setBookmarkData({ ...jsonResponse, error: false });
})
.catch((error) => {
setBookmarkData({ groups: [], error: error.message });
});
}, []);
useEffect(() => {
fetchBookmarkData();
}, [fetchBookmarkData]);
return { bookmarkData, fetchBookmarkData };
};
// SECTION: App data
export interface IAppDataProps {
categories: Array<IAppCategoryProps>;
apps: Array<IAppProps>;
error: string | boolean;
}
export const useAppData = () => {
const [appData, setAppData] = useState({
categories: [],
apps: [],
error: false,
});
const fetchAppData = useCallback(() => {
(process.env.NODE_ENV === "production"
? fetch("/data/apps.json").then(handleResponse)
: import("./data/apps.json")
)
.then((jsonResponse) => {
setAppData({ ...jsonResponse, error: false });
})
.catch((error) => {
setAppData({ categories: [], apps: [], error: error.message });
});
}, []);
useEffect(() => {
fetchAppData();
}, [fetchAppData]);
return { appData, fetchAppData };
};
// Section: Theme Data
export interface IThemeDataProps {
themes: Array<IThemeProps>;
error: string | boolean;
}
export const useThemeData = () => {
const [themeData, setThemeData] = useState<IThemeDataProps>({
themes: [],
error: false,
});
const fetchThemeData = useCallback(() => {
(process.env.NODE_ENV === "production"
? fetch("/data/themes.json").then(handleResponse)
: import("./data/themes.json")
)
.then((jsonResponse) => {
setThemeData({ ...jsonResponse, error: false });
})
.catch((error) => {
setThemeData({ themes: [], error: error.message });
});
}, []);
useEffect(() => {
fetchThemeData();
}, [fetchThemeData]);
return { themeData, fetchThemeData };
};
// SECTION: Imprint Data
export interface IImprintDataProps {
imprint: IImprintProps;
error: string | boolean;
}
export const useImprintData = () => {
const [imprintData, setImprintData] = useState<IImprintDataProps>({
imprint: {
name: { text: "", link: "" },
address: { text: "", link: "" },
phone: { text: "", link: "" },
email: { text: "", link: "" },
url: { text: "", link: "" },
},
error: false,
});
const fetchImprintData = useCallback(() => {
(process.env.NODE_ENV === "production"
? fetch("/data/imprint.json").then(handleResponse)
: import("./data/imprint.json")
)
.then((jsonResponse: any) => {
setImprintData({ ...jsonResponse, error: false });
})
.catch((error: any) => {
setImprintData({
imprint: {
name: { text: "", link: "" },
address: { text: "", link: "" },
phone: { text: "", link: "" },
email: { text: "", link: "" },
url: { text: "", link: "" },
},
error: error.message,
});
});
}, []);
useEffect(() => {
fetchImprintData();
}, [fetchImprintData]);
return { imprintData, fetchImprintData };
};

View file

@ -1,7 +1,7 @@
import React from "react";
import styled from "styled-components";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
const GreeterContainer = styled.div`
padding: 2rem 0;
@ -22,37 +22,6 @@ const DateText = styled.h3`
color: ${selectedTheme.accentColor};
`;
const getGreeting = () => {
switch (Math.floor(new Date().getHours() / 6)) {
case 0:
return "Good night!";
case 1:
return "Good morning!";
case 2:
return "Good afternoon!";
case 3:
return "Good evening!";
default:
break;
}
};
const getExtension = (day: number) => {
let extension = "";
if ((day > 4 && day <= 20) || (day > 20 && day % 10 >= 4)) {
extension = "th";
} else if (day % 10 === 1) {
extension = "st";
} else if (day % 10 === 2) {
extension = "nd";
} else if (day % 10 === 3) {
extension = "rd";
}
return extension;
};
const monthNames = [
"January",
"February",
@ -78,6 +47,50 @@ const weekDayNames = [
"Saturday",
];
/**
* Returns a greeting based on the current time
* @returns {string} - A greeting
*/
const getGreeting = () => {
switch (Math.floor(new Date().getHours() / 6)) {
case 0:
return "Good night!";
case 1:
return "Good morning!";
case 2:
return "Good afternoon!";
case 3:
return "Good evening!";
default:
break;
}
};
/**
* Returns the appropriate extension for a number (eg. 'rd' for '3' to make '3rd')
* @param {number} day - The number of a day within a month
* @returns {string} - The extension for that number
*/
const getExtension = (day: number) => {
let extension = "";
if ((day > 4 && day <= 20) || (day > 20 && day % 10 >= 4)) {
extension = "th";
} else if (day % 10 === 1) {
extension = "st";
} else if (day % 10 === 2) {
extension = "nd";
} else if (day % 10 === 3) {
extension = "rd";
}
return extension;
};
/**
* Generates the current date
* @returns {string} - The current date as a string
*/
const getDateString = () => {
let currentDate = new Date();
@ -93,16 +106,14 @@ const getDateString = () => {
);
};
const Greeter = () => {
let date = getDateString();
let greeting = getGreeting();
return (
/**
* Renders the Greeter
*/
const Greeter = () => (
<GreeterContainer>
<DateText>{date}</DateText>
<GreetText>{greeting}</GreetText>
<DateText>{getDateString()}</DateText>
<GreetText>{getGreeting()}</GreetText>
</GreeterContainer>
);
};
export default Greeter;

View file

@ -1,11 +1,24 @@
import React from "react";
import styled from "styled-components";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
export const RawIcon = styled.i`
interface IIconProps {
name: string;
size?: string;
}
/**
* Renders an Icon
* @param {IIconProps} props - The props needed for the given icon
*/
export const Icon = ({ name, size }: IIconProps) => {
let IconContainer = styled.i`
font-family: "Material Icons";
font-weight: normal;
font-style: normal;
font-size: ${size ? size : "24px"};
color: ${selectedTheme.mainColor};
display: inline-block;
line-height: 1;
text-transform: none;
@ -19,18 +32,7 @@ export const RawIcon = styled.i`
font-feature-settings: "liga";
`;
interface IIconProps {
name: string;
size?: string;
}
export const ComponentIcon = ({ name, size }: IIconProps) => {
let IconContainer = styled(RawIcon)`
font-size: ${size ? size : "24px"};
color: ${selectedTheme.mainColor};
`;
return <IconContainer>{name}</IconContainer>;
};
export default ComponentIcon;
export default Icon;

View file

@ -1,24 +1,16 @@
import React from "react";
import Modal from "./modal";
import styled from "styled-components";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
import {
ListContainer,
ItemList,
Headline as Hl,
SubHeadline as SHl,
Headline,
SubHeadline,
} from "./elements";
const Headline = styled(Hl)`
display: block;
padding: 1rem 0;
`;
const SubHeadline = styled(SHl)`
display: block;
`;
const ModalSubHeadline = styled(SubHeadline)`
display: block;
padding: 0.5rem 0;
`;
@ -56,30 +48,40 @@ export interface IImprintProps {
phone: IImprintFieldProps;
email: IImprintFieldProps;
url: IImprintFieldProps;
text: string;
}
interface IImprintFieldComponentProps {
field: IImprintFieldProps;
}
const ImprintField = ({ field }: IImprintFieldComponentProps) => (
<Link href={field.link}>{field.text}</Link>
);
interface IImprintComponentProps {
imprint: IImprintProps;
}
/**
* Renders an imprint field
* @param {IImprintFieldComponentProps} props - The data for the field
*/
const ImprintField = ({ field }: IImprintFieldComponentProps) => (
<Link href={field.link}>{field.text}</Link>
);
/**
* Renders the imprint component
* @param {IImprintProps} props - The contents of the imprint
*/
const Imprint = ({ imprint }: IImprintComponentProps) => (
<>
<ListContainer>
<Hl>About</Hl>
<Headline>About</Headline>
<ItemList>
<ItemContainer>
<SHl>Imprint</SHl>
<SubHeadline>Imprint</SubHeadline>
<Modal
element="text"
text="View Imprint"
title="Legal Disclosure"
condition={!window.location.href.endsWith("#imprint")}
onClose={() => {
if (window.location.href.endsWith("#imprint")) {
@ -88,7 +90,7 @@ const Imprint = ({ imprint }: IImprintComponentProps) => (
}
}}
>
<Headline>Legal Disclosure</Headline>
<div>
<ModalSubHeadline>
Information in accordance with section 5 TMG
</ModalSubHeadline>
@ -99,38 +101,13 @@ const Imprint = ({ imprint }: IImprintComponentProps) => (
{imprint.phone && <ImprintField field={imprint.phone} />}
{imprint.url && <ImprintField field={imprint.url} />}
</>
<Headline>Disclaimer</Headline>
<ModalSubHeadline>Accountability for content</ModalSubHeadline>
<Text>
The contents of our pages have been created with the utmost care.
However, we cannot guarantee the contents' accuracy, completeness
or topicality. According to statutory provisions, we are
furthermore responsible for our own content on these web pages. In
this matter, please note that we are not obliged to monitor the
transmitted or saved information of third parties, or investigate
circumstances pointing to illegal activity. Our obligations to
remove or block the use of information under generally applicable
laws remain unaffected by this as per §§ 8 to 10 of the Telemedia
Act (TMG).
</Text>
<ModalSubHeadline>Accountability for links</ModalSubHeadline>
<Text>
Responsibility for the content of external links (to web pages of
third parties) lies solely with the operators of the linked pages.
No violations were evident to us at the time of linking. Should
any legal infringement become known to us, we will remove the
respective link immediately.
</Text>
<ModalSubHeadline>Copyright</ModalSubHeadline>
<Text>
Our web pages and their contents are subject to German copyright
law. Unless expressly permitted by law, every form of utilizing,
reproducing or processing works subject to copyright protection on
our web pages requires the prior consent of the respective owner
of the rights. Individual reproductions of a work are only allowed
for private use. The materials from these pages are copyrighted
and any unauthorized use may violate copyright laws.
</Text>
</div>
<div>
<ModalSubHeadline>
Imprint
</ModalSubHeadline>
{imprint.text && <Text>{imprint.text}</Text>}
</div>
</Modal>
</ItemContainer>
</ItemList>

View file

@ -1,8 +1,8 @@
import React, { useState } from "react";
import styled from "styled-components";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
import { IconButton } from "./elements";
import { Headline, IconButton } from "./elements";
const ModalContainer = styled.div`
position: absolute;
@ -30,36 +30,51 @@ const Text = styled.p`
}
`;
interface IModalInterface {
const TitleContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
`;
interface IModalProps {
element: string;
icon?: string;
text?: string;
condition?: boolean;
title: string;
onClose?: () => void;
children: React.ReactNode;
}
const Modal = (props: IModalInterface) => {
const [modalHidden, setModalHidden] = useState(props.condition ?? true);
/**
* Renders a modal with button to hide and un-hide
* @param {IModalProps} props - The needed props for the modal
*/
const Modal = ({ element, icon, text, condition, title, onClose, children }: IModalProps) => {
const [modalHidden, setModalHidden] = useState(condition ?? true);
const closeModal = () => {
if (props.onClose) props.onClose();
if (onClose) onClose();
setModalHidden(!modalHidden);
};
return (
<>
{props.element === "icon" && (
<IconButton icon={props.icon ?? ""} onClick={() => closeModal()} />
{element === "icon" && (
<IconButton icon={icon ?? ""} onClick={() => closeModal()} />
)}
{props.element === "text" && (
<Text onClick={() => closeModal()}>{props.text}</Text>
{element === "text" && (
<Text onClick={() => closeModal()}>{text}</Text>
)}
<ModalContainer hidden={modalHidden}>
<TitleContainer>
<Headline>{title}</Headline>
<IconButton icon="close" onClick={() => closeModal()} />
{props.children}
</TitleContainer>
{children}
</ModalContainer>
</>
);

View file

@ -1,22 +1,43 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import selectedTheme from "./themeManager";
import selectedTheme from "../lib/theme";
import { Button } from "./elements";
const Search = styled.form`
width: 100%;
height: 2rem;
display: flex;
padding-top: 0.25rem;
`;
const SearchInput = styled.input`
width: 100%;
font-size: 1rem;
border: none;
border-bottom: 1px solid ${selectedTheme.accentColor};
background: none;
border-radius: 0;
color: ${selectedTheme.mainColor};
margin: 0px;
:focus {
outline: none;
}
`;
const SearchButton = styled(Button)`
margin: 0px 2px;
min-height: 0;
`;
export interface ISearchProviderProps {
name: string;
url: string;
@ -27,11 +48,20 @@ interface ISearchBarProps {
providers: Array<ISearchProviderProps> | undefined;
}
/**
* Renders a search bar
* @param {ISearchBarProps} props - The search providers for the search bar to use
*/
const SearchBar = ({ providers }: ISearchBarProps) => {
let [input, setInput] = useState("");
let [input, setInput] = useState<string>("");
let [buttonsHidden, setButtonsHidden] = useState<boolean>(true);
useEffect(() => {
setButtonsHidden(input === "");
}, [input]);
const handleSearchQuery = (e: React.FormEvent) => {
var query = input || "";
var query: string = input || "";
if (query.split(" ")[0].includes("/")) {
handleQueryWithProvider(query);
@ -43,13 +73,14 @@ const SearchBar = ({ providers }: ISearchBarProps) => {
};
const handleQueryWithProvider = (query: string) => {
let queryArray = query.split(" ");
let prefix = queryArray[0];
let queryArray: Array<string> = query.split(" ");
let prefix: string = queryArray[0];
queryArray.shift();
let searchQuery = queryArray.join(" ");
let searchQuery: string = queryArray.join(" ");
let providerFound = false;
let providerFound: boolean = false;
if (providers) {
providers.forEach((provider: ISearchProviderProps) => {
if (provider.prefix === prefix) {
@ -64,13 +95,25 @@ const SearchBar = ({ providers }: ISearchBarProps) => {
};
return (
<form onSubmit={(e) => handleSearchQuery(e)}>
<Search onSubmit={(e) => handleSearchQuery(e)}>
<SearchInput
type="text"
onChange={(e) => setInput(e.target.value)}
value={input}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setInput(e.target.value)
}
></SearchInput>
<button type="submit" hidden />
</form>
<SearchButton
type="button"
onClick={() => setInput("")}
hidden={buttonsHidden}
>
Clear
</SearchButton>
<SearchButton type="submit" hidden={buttonsHidden}>
Search
</SearchButton>
</Search>
);
};

View file

@ -1,21 +1,24 @@
import React, { useState } from "react";
import styled from "styled-components";
import Select from "react-select";
import Select, { Styles } from "react-select";
import { ISearchProviderProps } from "./searchBar";
import selectedTheme, { setTheme, IThemeProps } from "./themeManager";
import { Button, Headline as hl } from "./elements";
import selectedTheme, { setTheme, IThemeProps } from "../lib/theme";
import { Button, SubHeadline } from "./elements";
import Modal from "./modal";
const Headline = styled(hl)`
padding: 0.5rem 0;
`;
interface IHoverProps {
color?: string;
backgroundColor?: string;
border?: string;
borderColor?: string;
}
const SelectContainer = styled.div`
padding-bottom: 1rem;
`;
interface IPseudoProps extends React.CSSProperties {
"&:hover": IHoverProps
}
const FormContainer = styled.div`
display: grid;
@ -42,47 +45,72 @@ const HeadCell = styled.th`
background: none;
`;
const SelectorStyle = {
control: (provided: any) => ({
...provided,
fontWeight: "500",
const Section = styled.div`
padding: 1rem 0;
`;
const SectionHeadline = styled(SubHeadline)`
width: 100%;
border-bottom: 1px solid ${selectedTheme.accentColor};
margin-bottom: 0.5rem;
`;
const SelectorStyle: Partial<Styles<IThemeProps, false>> = {
indicatorSeparator: () => ({
display: "none",
}),
container: (base: React.CSSProperties): React.CSSProperties => ({
...base,
margin: "0 2px",
}),
dropdownIndicator: (base: React.CSSProperties): IPseudoProps => ({
...base,
color: selectedTheme.mainColor,
"&:hover": {
color: selectedTheme.mainColor
}
}),
control: (base: React.CSSProperties): IPseudoProps => ({
...base,
fontWeight: 500,
color: selectedTheme.mainColor,
textTransform: "uppercase",
width: "12rem",
background: "none",
borderRadius: "0px",
border: "1px solid " + selectedTheme.mainColor,
boxShadow: 0,
borderRadius: 0,
border: "1px solid",
borderColor: selectedTheme.mainColor,
boxShadow: "none",
"&:hover": {
border: "1px solid " + selectedTheme.mainColor,
border: "1px solid",
borderColor: selectedTheme.mainColor
},
}),
menu: (provided: any) => ({
...provided,
menu: (base: React.CSSProperties): React.CSSProperties => ({
...base,
backgroundColor: selectedTheme.backgroundColor,
border: "1px solid " + selectedTheme.mainColor,
borderRadius: 0,
boxShadow: 0,
boxShadow: "none",
margin: "4px 0"
}),
option: (provided: any) => ({
...provided,
fontWeight: "500",
option: (base: React.CSSProperties): IPseudoProps => ({
...base,
fontWeight: 500,
color: selectedTheme.mainColor,
textTransform: "uppercase",
borderRadius: 0,
boxShadow: 0,
boxShadow: "none",
backgroundColor: selectedTheme.backgroundColor,
"&:hover": {
backgroundColor: selectedTheme.mainColor,
color: selectedTheme.backgroundColor,
},
}),
singleValue: (provided: any) => {
return {
...provided,
singleValue: (base: React.CSSProperties): React.CSSProperties => ({
...base,
color: selectedTheme.mainColor,
};
},
}),
};
interface ISettingsProps {
@ -90,15 +118,21 @@ interface ISettingsProps {
providers: Array<ISearchProviderProps> | undefined;
}
/**
* Handles the settings-modal
* @param {Array<IThemeProps>} themes - the list of themes a user can select between
* @param {Array<ISearchProviderProps>} providers - the list of search providers
*/
const Settings = ({ themes, providers }: ISettingsProps) => {
const [newTheme, setNewTheme] = useState();
if (themes && providers) {
return (
<Modal element="icon" icon="settings">
<Modal element="icon" icon="settings" title="Settings">
{themes && (
<SelectContainer>
<Headline>Theme:</Headline>
<Section>
<SectionHeadline>Theme:</SectionHeadline>
<FormContainer>
<Select
options={themes}
@ -108,15 +142,16 @@ const Settings = ({ themes, providers }: ISettingsProps) => {
}}
styles={SelectorStyle}
/>
<Button onClick={() => setTheme(JSON.stringify(newTheme))}>
Apply
</Button>
<Button onClick={() => window.location.reload()}>Refresh</Button>
</FormContainer>
</SelectContainer>
</Section>
)}
{providers && (
<Section>
<SectionHeadline>Search Providers</SectionHeadline>
<Table>
<tbody>
<TableRow>
@ -131,6 +166,7 @@ const Settings = ({ themes, providers }: ISettingsProps) => {
))}
</tbody>
</Table>
</Section>
)}
</Modal>
);

View file

@ -19,6 +19,7 @@
"url": {
"text": "example.com",
"link": "#"
}
},
"text": "This is the place where you should put whatever you want it to say on the imprint page."
}
}

173
src/lib/fetcher.tsx Normal file
View file

@ -0,0 +1,173 @@
import { useCallback, useEffect, useState } from "react";
import { ISearchProviderProps } from "../components/searchBar";
import { IBookmarkGroupProps } from "../components/bookmarkGroup";
import { IAppCategoryProps } from "../components/appCategory";
import { IAppProps } from "../components/app";
import { IThemeProps } from "./theme";
import { IImprintProps } from "../components/imprint";
const errorMessage = "Failed to load data.";
const inProduction = process.env.NODE_ENV === "production";
/**
* Handles the response from the fetch requests
* @param {Response} response - The response given by the fetch request
* @returns - The response in JSON
* @throws - Error with given error message if request failed
*/
const handleResponse = (response: Response) => {
if (response.ok) return response.json();
throw new Error(errorMessage);
};
export interface ISearchProviderDataProps {
providers: Array<ISearchProviderProps>;
error: string | boolean;
}
export interface IBookmarkDataProps {
groups: Array<IBookmarkGroupProps>;
error: string | boolean;
}
export interface IAppDataProps {
categories: Array<IAppCategoryProps>;
apps: Array<IAppProps>;
error: string | boolean;
}
export interface IThemeDataProps {
themes: Array<IThemeProps>;
error: string | boolean;
}
export interface IImprintDataProps {
imprint: IImprintProps;
error: string | boolean;
}
/**
* Default values for the respective state variables
*/
const defaults = {
app: {
categories: [],
apps: [],
error: false,
},
bookmark: {
groups: [],
error: false,
},
search: {
providers: [],
error: false,
},
theme: {
themes: [],
error: false,
},
imprint: {
imprint: {
name: { text: "", link: "" },
address: { text: "", link: "" },
phone: { text: "", link: "" },
email: { text: "", link: "" },
url: { text: "", link: "" },
text: "",
},
error: false,
},
};
/**
* Handles fetch errors by returning the error message.
* @param {string} type - The type of fetch request that threw an error
* @param {Error} error - The error itself
*/
const handleError = (status: string, error: Error) => {
switch (status) {
case "apps":
return { ...defaults.app, error: error.message }
case "bookmark":
return { ...defaults.bookmark, error: error.message }
case "searchProvider":
return { ...defaults.search, error: error.message }
case "theme":
return { ...defaults.theme, error: error.message }
case "imprint":
return { ...defaults.imprint, error: error.message }
default:
break;
}
}
/**
* Fetches all of the data by doing fetch requests (only available in production)
*/
const fetchProduction = Promise.all([
fetch("/data/apps.json").then(handleResponse).catch((error: Error) => handleError("apps", error)),
fetch("/data/bookmarks.json").then(handleResponse).catch((error: Error) => handleError("bookmark", error)),
fetch("/data/search.json").then(handleResponse).catch((error: Error) => handleError("searchProvider", error)),
fetch("/data/themes.json").then(handleResponse).catch((error: Error) => handleError("theme", error)),
fetch("/data/imprint.json").then(handleResponse).catch((error: Error) => handleError("imprint", error)),
]);
/**
* Fetches all of the data by importing it (only available in development)
*/
const fetchDevelopment = Promise.all([
import("../data/apps.json"),
import("../data/bookmarks.json"),
import("../data/search.json"),
import("../data/themes.json"),
import("../data/imprint.json"),
]);
/**
* Fetches app, bookmark, search, theme and imprint data and returns it.
*/
export const useFetcher = () => {
const [appData, setAppData] = useState<IAppDataProps>(defaults.app);
const [bookmarkData, setBookmarkData] = useState<IBookmarkDataProps>(
defaults.bookmark
);
const [
searchProviderData,
setSearchProviderData,
] = useState<ISearchProviderDataProps>(defaults.search);
const [themeData, setThemeData] = useState<IThemeDataProps>(defaults.theme);
const [imprintData, setImprintData] = useState<IImprintDataProps>(
defaults.imprint
);
const callback = useCallback(() => {
(inProduction ? fetchProduction : fetchDevelopment).then(
([appData, bookmarkData, searchData, themeData, imprintData]: any) => {
(appData.error) ? setAppData(appData) : setAppData({ ...appData, error: false });
(bookmarkData.error) ? setBookmarkData(bookmarkData) : setBookmarkData({ ...bookmarkData, error: false });
(searchData.error) ? setSearchProviderData(searchData) : setSearchProviderData({ ...searchData, error: false });
(themeData.error) ? setThemeData(themeData) : setThemeData({ ...themeData, error: false });
(imprintData.error) ? setImprintData(imprintData) : setImprintData({ ...imprintData, error: false });
}
);
}, []);
useEffect(() => callback(), [callback]);
return {
appData,
bookmarkData,
searchProviderData,
themeData,
imprintData,
callback
};
};
export default useFetcher;

View file

@ -14,22 +14,23 @@ const defaultTheme: IThemeProps = {
backgroundColor: "#ffffff",
};
/**
* Writes a given theme into localStorage
* @param {string} theme - the theme that shall be saved (in stringified JSON)
*/
export const setTheme = (theme: string) => {
if (theme !== undefined) {
localStorage.setItem("theme", theme);
}
if (theme !== undefined) localStorage.setItem("theme", theme);
window.location.reload();
};
export const resetTheme = () => {
localStorage.removeItem("theme");
};
const getTheme = () => {
/**
* Function that gets the saved theme from localStorage or returns the default
* @returns {IThemeProps} the saved theme or the default theme
*/
const getTheme = (): IThemeProps => {
let selectedTheme = defaultTheme;
if (localStorage.getItem("theme") != null) {
if (localStorage.getItem("theme") !== null) {
selectedTheme = JSON.parse(localStorage.getItem("theme") || "{}");
}
@ -37,5 +38,4 @@ const getTheme = () => {
};
const selectedTheme = getTheme();
export default selectedTheme;