This commit is contained in:
phntxx 2021-03-21 19:59:18 +01:00
parent 9fef36eae3
commit 8e3dd6e77d
19 changed files with 327 additions and 335 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

339
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,137 @@ $ 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"
### Apps
services:
dashboard:
image: phntxx/dashboard:latest
restart: unless-stopped
volumes:
- [path to data directory]:/app/data
ports:
- 8080:8080
```
To show the apps you want to show, change `apps.json` to resemble the following:
**Note: You might still need to clone the repository in order to get the JSON-files which are required for the
app to run**
## 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]",
"displayURL": "[URL you want to show]",
"URL": "[URL to redirect to]",
"icon": "[Icon code]"
"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]",
"items": [
{
"name": "[Bookmark name]",
"url": "[URL to redirect to]"
},
{
"name": "[Bookmark name]",
"url": "[URL to redirect to]"
},
{
"name": "[Bookmark name]",
"url": "[URL to redirect to]"
}
...
]
},
...
```
### Theming:
Dashboard also supports themes with the help of a simple JSON-file: themes.json. To add a theme, append the following to themes.json:
```
{
"label": "[Theme Name]",
"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]"
"groups": [
{
"name": "[Group Name]",
"items": [
{
"name": "[Bookmark Name]",
"url": "[Bookmark URL]"
},
...
]
},
...
]
}
```
### Search Providers:
### Themes
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
In order to customize theming, `themes.json` needs to resemble this:
```
```json
{
"name":"[Name of the website]",
"url":"[Link that processes searches on that website]",
"prefix":"[a custom prefix]"
},
"themes": [
{
"label": "[Theme Name]",
"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
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 (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

@ -28,7 +28,6 @@ const GlobalStyle = createGlobalStyle`
/**
* Renders the entire app by calling individual components
* @returns
*/
const App = () => {

View file

@ -46,22 +46,21 @@ const AppDescription = styled.p`
export interface IAppProps {
name: string;
icon: string;
URL: string;
url: string;
displayURL: string;
}
/**
* Renders one app in the list
* @param
* @returns
* Renders a single app shortcut
* @param {IAppProps} props - The props of the given app
*/
export const App = ({ name, icon, URL, displayURL }: IAppProps) => (
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>}

View file

@ -9,22 +9,24 @@ export interface IAppListProps {
apps: Array<IAppProps>;
}
const AppList = ({ categories, apps }: IAppListProps) => {
return (
<ListContainer>
<Headline>Applications</Headline>
{categories &&
categories.map(({ name, items }, idx) => (
<AppCategory key={[name, idx].join("")} name={name} items={items} />
))}
{apps && (
<AppCategory
name={categories ? "Uncategorized apps" : ""}
items={apps}
/>
)}
</ListContainer>
);
};
/**
* 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 &&
categories.map(({ name, items }, idx) => (
<AppCategory key={[name, idx].join("")} name={name} items={items} />
))}
{apps && (
<AppCategory
name={categories ? "Uncategorized apps" : ""}
items={apps}
/>
)}
</ListContainer>
);
export default AppList;

View file

@ -28,14 +28,15 @@ export interface IBookmarkProps {
}
export interface IBookmarkGroupProps {
name: string;
groupName: string;
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 = ({ groupName, items }: IBookmarkGroupProps) => (
<Item>
<GroupContainer>
<SubHeadline>{groupName}</SubHeadline>

View file

@ -1,20 +1,22 @@
import React from "react";
import { Headline, ListContainer, ItemList } from "./elements";
import { BookmarkGroup, IBookmarkGroupProps } from "./bookmarkGroup";
interface IBookmarkListProps {
groups: Array<IBookmarkGroupProps>;
}
/**
* Renders a given list of categorized bookmarks
* @param {IBookmarkListProps} props - The props of the given bookmark list
*/
const BookmarkList = ({ groups }: IBookmarkListProps) => {
return (
<ListContainer>
<Headline>Bookmarks</Headline>
<ItemList>
{groups.map(({ name, items }, idx) => (
<BookmarkGroup key={[name, idx].join("")} name={name} items={items} />
{groups.map(({ groupName, items }, idx) => (
<BookmarkGroup key={[groupName, idx].join("")} groupName={groupName} items={items} />
))}
</ItemList>
</ListContainer>

View file

@ -3,8 +3,6 @@ import styled from "styled-components";
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;
`;
@ -75,6 +73,10 @@ interface IIconButtonProps {
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

@ -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,6 +106,9 @@ const getDateString = () => {
);
};
/**
* Renders the Greeter
*/
const Greeter = () => {
let date = getDateString();
let greeting = getGreeting();

View file

@ -7,26 +7,30 @@ interface IIconProps {
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;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "liga";
`;
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;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "liga";
`;
return <IconContainer>{name}</IconContainer>;
};

View file

@ -5,16 +5,11 @@ 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 ModalSubHeadline = styled(SHl)`
const ModalSubHeadline = styled(SubHeadline)`
display: block;
padding: 0.5rem 0;
`;
@ -53,27 +48,36 @@ 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"
@ -96,37 +100,8 @@ 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.
{imprint.text}
</Text>
</Modal>
</ItemContainer>

View file

@ -37,7 +37,7 @@ const TitleContainer = styled.div`
justify-content: space-between;
`;
interface IModalInterface {
interface IModalProps {
element: string;
icon?: string;
text?: string;
@ -47,7 +47,11 @@ interface IModalInterface {
children: React.ReactNode;
}
const Modal = ({ element, icon, text, condition, title, onClose, children }: IModalInterface) => {
/**
* 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 = () => {

View file

@ -48,6 +48,10 @@ 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<string>("");
let [buttonsHidden, setButtonsHidden] = useState<boolean>(true);

View file

@ -9,13 +9,6 @@ import { Button, SubHeadline } from "./elements";
import Modal from "./modal";
/**
* Complementary code to get hover pseudo-classes working in React
* @param color the color of the element on hover
* @param backgroundColor the background color of the element on hover
* @param border the border of the element on hover
* @param borderColor the border color of the element on hover
*/
interface IHoverProps {
color?: string;
backgroundColor?: string;
@ -125,6 +118,12 @@ 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();
@ -132,9 +131,6 @@ const Settings = ({ themes, providers }: ISettingsProps) => {
return (
<Modal element="icon" icon="settings" title="Settings">
{themes && (
<Section>
<SectionHeadline>Theme:</SectionHeadline>
<FormContainer>
@ -154,7 +150,6 @@ const Settings = ({ themes, providers }: ISettingsProps) => {
</Section>
)}
{providers && (
<Section>
<SectionHeadline>Search Providers</SectionHeadline>
<Table>
@ -172,7 +167,6 @@ 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."
}
}

View file

@ -10,7 +10,13 @@ import { IImprintProps } from "../components/imprint";
const errorMessage = "Failed to load data.";
const inProduction = process.env.NODE_ENV === "production";
const handleResponse = (response: any) => {
/**
* 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);
};
@ -69,6 +75,7 @@ const defaults = {
phone: { text: "", link: "" },
email: { text: "", link: "" },
url: { text: "", link: "" },
text: "",
},
error: false,
},
@ -97,7 +104,7 @@ const handleError = (status: string, error: Error) => {
}
/**
* Fetches all of the data by doing fetch requests
* 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)),
@ -120,7 +127,6 @@ const fetchDevelopment = Promise.all([
/**
* Fetches app, bookmark, search, theme and imprint data and returns it.
* @returns all of the data the function was able to fetch and the callback function to refresh the data
*/
export const useFetcher = () => {
const [appData, setAppData] = useState<IAppDataProps>(defaults.app);
@ -159,7 +165,8 @@ export const useFetcher = () => {
bookmarkData,
searchProviderData,
themeData,
imprintData, callback
imprintData,
callback
};
};

View file

@ -14,15 +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);
window.location.reload();
};
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") || "{}");
}