Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553be7da33 | ||
|
|
260b850e1a | ||
|
|
726a4fddd3 | ||
|
|
318c094f27 | ||
|
|
6e0d3807e4 | ||
|
|
10e9dc06dd | ||
|
|
e84687e5fc | ||
|
|
361d41065c | ||
|
|
4c0fbc0b42 | ||
|
|
ef8e380956 | ||
|
|
5db28b1607 | ||
|
|
dbfd4cf050 | ||
|
|
ffd298a2b6 | ||
|
|
9b1b5906e7 | ||
|
|
19bd14c63d | ||
|
|
b69343af56 | ||
|
|
94ee90eebb | ||
|
|
72b3097ad1 | ||
|
|
225f910fe8 | ||
|
|
10d9ffc740 | ||
|
|
4202d25d62 | ||
|
|
6a905e1b49 | ||
|
|
72e08f484f | ||
|
|
64dbb9c025 | ||
|
|
af2e0235bf | ||
|
|
bf85818f8b | ||
|
|
1840713179 | ||
|
|
b11bffb7cf | ||
|
|
bfb26a9402 | ||
|
|
c3b11be2d0 | ||
|
|
ecfb89de40 | ||
|
|
e1eab70f93 | ||
|
|
adb341c0fa | ||
|
|
25ccdffeb9 | ||
|
|
b98d399a9c | ||
|
|
f36e7b8abb | ||
|
|
667322d14e | ||
|
|
9b440c0da3 | ||
|
|
2586733a98 | ||
|
|
7bc779b296 | ||
|
|
6064dcb6a6 | ||
|
|
7c7b0cc970 | ||
|
|
c182397dd9 | ||
|
|
dc5ee3bdf3 | ||
|
|
c8e1295a4b | ||
|
|
331c55240b | ||
|
|
65037f9b56 | ||
|
|
39853d79ce | ||
|
|
8530550347 | ||
|
|
ba8e9ef63c | ||
|
|
119f2d7e51 | ||
|
|
b0be26300e | ||
|
|
0400188ea7 | ||
|
|
879581224a | ||
|
|
7e5602c881 | ||
|
|
4870ea3e40 | ||
|
|
61c55acd50 | ||
|
|
c45421d27e | ||
|
|
b396d2604f | ||
|
|
28b6dcd1db | ||
|
|
1dd74ea7da | ||
|
|
64923b03d9 | ||
|
|
2ba9d517a8 | ||
|
|
471a9f7407 | ||
|
|
bdf871b476 | ||
|
|
ab860eeea1 | ||
|
|
50d760f3b8 | ||
|
|
73d06e15fb | ||
|
|
49d57024b9 | ||
|
|
31deb5010f | ||
|
|
e86eb7798f | ||
|
|
2896423766 | ||
|
|
696d0c582d | ||
|
|
e94cae620a | ||
|
|
c9c6f2b0c9 | ||
|
|
b8fe799ac6 | ||
|
|
4cb8539143 | ||
|
|
16b86870c4 | ||
|
|
d4ce2a3ed6 | ||
|
|
a474f3e4ee | ||
|
|
9a49fbb747 | ||
|
|
e3d47d78e0 | ||
|
|
d62189f086 | ||
|
|
bb1b3d7d9a | ||
|
|
13aeeefb22 | ||
|
|
8cdc9c3e29 | ||
|
|
3e31a4d38e | ||
|
|
0cb3db6b89 | ||
|
|
b7e8c51b29 | ||
|
|
e60db9f57a | ||
|
|
2c707e86aa |
@@ -20,6 +20,7 @@ module.exports = {
|
||||
},
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/no-children-prop': 'off',
|
||||
"unused-imports/no-unused-imports": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-imports": "off",
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
module.exports = {
|
||||
stories: ['../src/components/**/*.story.mdx', '../src/components/**/*.story.*'],
|
||||
addons: [
|
||||
'storybook-dark-mode',
|
||||
'@storybook/addon-links',
|
||||
'storybook-addon-mock/register',
|
||||
'@storybook/addon-essentials',
|
||||
{
|
||||
name: 'storybook-addon-turbo-build',
|
||||
options: { optimizationLevel: 2 },
|
||||
},
|
||||
],
|
||||
typescript: {
|
||||
check: false,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useDarkMode } from 'storybook-dark-mode';
|
||||
import { MantineProvider, ColorSchemeProvider } from '@mantine/core';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
|
||||
@@ -7,11 +6,7 @@ export const parameters = { layout: 'fullscreen' };
|
||||
function ThemeWrapper(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ColorSchemeProvider colorScheme="light" toggleColorScheme={() => {}}>
|
||||
<MantineProvider
|
||||
theme={{ colorScheme: useDarkMode() ? 'dark' : 'light' }}
|
||||
withGlobalStyles
|
||||
withNormalizeCSS
|
||||
>
|
||||
<MantineProvider withGlobalStyles withNormalizeCSS>
|
||||
<NotificationsProvider>{props.children}</NotificationsProvider>
|
||||
</MantineProvider>
|
||||
</ColorSchemeProvider>
|
||||
|
||||
31
README.md
31
README.md
@@ -1,6 +1,9 @@
|
||||
<h3 align="center">Homarr</h3>
|
||||
<br>
|
||||
<p align="center">
|
||||
<i>Don't forget to star the repo if you enjoy the Homarr project!</i>
|
||||
<br>
|
||||
<img src="https://img.shields.io/github/stars/ajnart/homarr?label=%E2%AD%90%20Stars&style=flat-square?branch=master&kill_cache=1%22">
|
||||
<a href="https://github.com/ajnart/homarr/actions/workflows/docker.yml">
|
||||
<img title="Docker CI Status" src="https://github.com/ajnart/homarr/actions/workflows/docker.yml/badge.svg" alt="CI Status"></a>
|
||||
<a href="https://github.com/ajnart/homarr/releases/latest">
|
||||
@@ -9,7 +12,8 @@
|
||||
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/ajnart/homarr?label=Downloads%20"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img align="end" width=600 src="https://user-images.githubusercontent.com/49837342/168315259-b778c816-10fe-44db-bd25-3eea6f31b233.png" />
|
||||
<img align="end" width=600 src="https://user-images.githubusercontent.com/71191962/169860380-856634fb-4f41-47cb-ba54-6a9e7b3b9c81.gif" />
|
||||
|
||||
</p>
|
||||
<p align = "center">
|
||||
A homepage for <i>your</i> server.
|
||||
@@ -21,7 +25,7 @@
|
||||
<br />
|
||||
<a href = "https://discord.gg/aCsmEV5RgA" > <img title="Discord" src="https://discordapp.com/api/guilds/972958686051962910/widget.png?style=shield" > </a>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
# 📃 Table of Contents
|
||||
@@ -32,6 +36,8 @@
|
||||
- [⚡ Installation](#-installation)
|
||||
- [🐳 Deploying from Docker Image](#-deploying-from-docker-image)
|
||||
- [🛠️ Building from Source](#%EF%B8%8F-building-from-source)
|
||||
- [📖 Guides](#-guides)
|
||||
- [🔁 Drag and Drop (Rearrange)](#-drag-and-drop-rearrange)
|
||||
- [🔧 Configuration](#-configuration)
|
||||
- [🧩 Integrations](#--integrations)
|
||||
- [🧑🤝🧑 Multiple Configs](#-multiple-configs)
|
||||
@@ -39,6 +45,7 @@
|
||||
- [📊 Modules](#-modules)
|
||||
- [🔍 Search Bar](#-search-bar)
|
||||
- [💖 Contributing](#-contributing)
|
||||
- [🍏 Request Icons](#-request-icons)
|
||||
|
||||
|
||||
<!-- Getting Started -->
|
||||
@@ -52,8 +59,6 @@ Homarr is a simple and lightweight homepage for your server, that helps you easi
|
||||
|
||||
## 💥 Known Issues
|
||||
- Posters on the Calendar get blocked by adblockers. (IMDb posters)
|
||||
- Editing a service creates a duplicate (#97)
|
||||
- Used search engine not properly selected (#35)
|
||||
|
||||
**[⤴️ Back to Top](#-table-of-contents)**
|
||||
|
||||
@@ -105,6 +110,11 @@ _Requirements_:
|
||||
- Start the NextJS web server: ``yarn start``
|
||||
- *Note: If you want to update the code in real time, launch with ``yarn dev``*
|
||||
|
||||
## 📖 Guides
|
||||
|
||||
### 🔁 Drag and Drop (Rearrange)
|
||||
You can rearrange items by Drag and Dropping them to any position. To Drag an Drop, click and hold an icon for 250ms and then drag it to the desired position.
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### 🧩 Integrations
|
||||
@@ -168,10 +178,13 @@ Icons are requested in the following way: <br>
|
||||
Modules are blocks shown on the sides of the Homarr dashboard that display information. They can be enabled in settings.
|
||||
|
||||
**Clock Module**
|
||||
The clock module will display your current time and date.
|
||||
The Clock Module will display your current time and date.
|
||||
|
||||
**Calendar Module**
|
||||
The Calendar module uses [integrations](#--integrations-1) to display new content.
|
||||
The Calendar Module uses [integrations](#--integrations-1) to display new content.
|
||||
|
||||
**Weather Module**
|
||||
The Weather Module uses your devices location to display the current, highest, and lowest temperature.
|
||||
|
||||
**[⤴️ Back to Top](#-table-of-contents)**
|
||||
|
||||
@@ -187,5 +200,11 @@ The Search Bar will open any Search Query after the Query URL you've specified i
|
||||
**Please read our [Contribution Guidelines](/CONTRIBUTING.md)**
|
||||
|
||||
All contributions are highly appreciated.
|
||||
|
||||
**[⤴️ Back to Top](#-table-of-contents)**
|
||||
|
||||
## 🍏 Request Icons
|
||||
|
||||
The icons used in Homarr are automatically requested from the [dashboard-icons](https://github.com/walkxhub/dashboard-icons) repo. You can make a icon request by creating an [issue](https://github.com/walkxhub/dashboard-icons/issues/new/choose).
|
||||
|
||||
**[⤴️ Back to Top](#-table-of-contents)**
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "config",
|
||||
"services": [
|
||||
{
|
||||
"type": "Other",
|
||||
"name": "YouTube",
|
||||
"icon": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png",
|
||||
"url": "https://youtube.com/"
|
||||
},
|
||||
{
|
||||
"type": "Other",
|
||||
"name": "YouTube ",
|
||||
"icon": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png",
|
||||
"url": "https://youtube.com/"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"searchBar": true,
|
||||
"searchUrl": "Custom",
|
||||
"enabledModules": [
|
||||
"Date",
|
||||
"Calendar",
|
||||
"Weather"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "config_new",
|
||||
"services": [
|
||||
{
|
||||
"type": "Other",
|
||||
"name": "example",
|
||||
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"searchBar": true,
|
||||
"searchUrl": "https://duckduckgo.com/?q=",
|
||||
"enabledModules": []
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,14 @@
|
||||
"name": "default",
|
||||
"services": [
|
||||
{
|
||||
"type": "Other",
|
||||
"name": "example",
|
||||
"id": "09c45847-8afc-4c1a-9697-f03192de948a",
|
||||
"type": "Other",
|
||||
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"searchBar": true,
|
||||
"searchUrl": "https://bing.com/search?q=",
|
||||
"enabledModules": []
|
||||
"searchUrl": "https://bing.com/search?q="
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export const REPO_URL = 'ajnart/homarr';
|
||||
export const CURRENT_VERSION = 'v0.3.1';
|
||||
export const CURRENT_VERSION = 'v0.5.0';
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
const { env } = require('process');
|
||||
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
});
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: true,
|
||||
reactStrictMode: false,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
experimental: {
|
||||
outputStandalone: true,
|
||||
},
|
||||
basePath: env.BASE_URL,
|
||||
});
|
||||
|
||||
149
package.json
149
package.json
@@ -1,87 +1,80 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": "false",
|
||||
"description": "Homarr - A homepage for your server.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ajnart/homarr"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"start": "next start --port 7575",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"export": "next build && next export",
|
||||
"lint": "next lint",
|
||||
"jest": "jest",
|
||||
"jest:watch": "jest --watch",
|
||||
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
||||
"storybook": "start-storybook -p 7001",
|
||||
"storybook:build": "build-storybook",
|
||||
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^4.2.4",
|
||||
"@mantine/dates": "^4.2.4",
|
||||
"@mantine/dropzone": "^4.2.4",
|
||||
"@mantine/form": "^4.2.4",
|
||||
"@mantine/hooks": "^4.2.4",
|
||||
"@mantine/modals": "^4.2.4",
|
||||
"@mantine/next": "^4.2.4",
|
||||
"@mantine/notifications": "^4.2.4",
|
||||
"@mantine/prism": "^4.2.4",
|
||||
"@mantine/rte": "^4.2.4",
|
||||
"@mantine/spotlight": "^4.2.4",
|
||||
"@modulz/radix-icons": "^4.0.0",
|
||||
"axios": "^0.27.2",
|
||||
"cookies-next": "^2.0.4",
|
||||
"dayjs": "^1.11.2",
|
||||
"framer-motion": "^6.3.1",
|
||||
"js-file-download": "^0.4.12",
|
||||
"next": "12.1.5-canary.4",
|
||||
"prism-react-renderer": "^1.3.1",
|
||||
"react": "18.0.0",
|
||||
"react-dom": "18.0.0",
|
||||
"tabler-icons-react": "^1.46.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.8",
|
||||
"@next/bundle-analyzer": "^12.1.4",
|
||||
"@next/eslint-plugin-next": "^12.1.4",
|
||||
"@storybook/addon-essentials": "^6.4.22",
|
||||
"@storybook/addon-links": "^6.4.22",
|
||||
"@storybook/react": "^6.4.22",
|
||||
"@testing-library/dom": "^8.12.0",
|
||||
"@testing-library/jest-dom": "^5.16.3",
|
||||
"@testing-library/react": "^13.0.0",
|
||||
"@testing-library/user-event": "^14.0.4",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/react": "17.0.43",
|
||||
"@typescript-eslint/eslint-plugin": "^5.16.0",
|
||||
"@typescript-eslint/parser": "^5.16.0",
|
||||
"babel-loader": "^8.2.4",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^16.1.4",
|
||||
"eslint-config-mantine": "1.1.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jest": "^26.1.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-storybook": "^0.5.11",
|
||||
"eslint-plugin-testing-library": "^5.2.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"prettier": "^2.6.2",
|
||||
"storybook-addon-turbo-build": "^1.1.0",
|
||||
"storybook-dark-mode": "^1.0.9",
|
||||
"ts-jest": "^27.1.4",
|
||||
"typescript": "4.6.3"
|
||||
}
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"analyze": "ANALYZE=true next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"export": "next build && next export",
|
||||
"lint": "next lint",
|
||||
"jest": "jest",
|
||||
"jest:watch": "jest --watch",
|
||||
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"test": "npm run prettier:check && npm run lint && npm run typecheck && npm run jest",
|
||||
"storybook": "start-storybook -p 7001",
|
||||
"storybook:build": "build-storybook",
|
||||
"ci": "yarn test && yarn lint --fix && yarn typecheck && yarn prettier:write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.0",
|
||||
"@mantine/core": "^4.2.6",
|
||||
"@mantine/dates": "^4.2.6",
|
||||
"@mantine/dropzone": "^4.2.6",
|
||||
"@mantine/form": "^4.2.6",
|
||||
"@mantine/hooks": "^4.2.6",
|
||||
"@mantine/next": "^4.2.6",
|
||||
"@mantine/notifications": "^4.2.6",
|
||||
"@mantine/prism": "^4.2.6",
|
||||
"axios": "^0.27.2",
|
||||
"cookies-next": "^2.0.4",
|
||||
"dayjs": "^1.11.2",
|
||||
"framer-motion": "^6.3.1",
|
||||
"js-file-download": "^0.4.12",
|
||||
"next": "12.1.6",
|
||||
"prism-react-renderer": "^1.3.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"tabler-icons-react": "^1.46.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.8",
|
||||
"@next/bundle-analyzer": "^12.1.4",
|
||||
"@next/eslint-plugin-next": "^12.1.4",
|
||||
"@storybook/react": "^6.5.4",
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/react": "17.0.43",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.16.0",
|
||||
"@typescript-eslint/parser": "^5.16.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^16.1.0",
|
||||
"eslint-config-mantine": "1.1.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"eslint-plugin-jest": "^26.1.3",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-storybook": "^0.5.11",
|
||||
"eslint-plugin-testing-library": "^5.2.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"jest": "^28.1.0",
|
||||
"prettier": "^2.6.2",
|
||||
"require-from-string": "^2.0.2",
|
||||
"typescript": "4.6.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.30"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,17 @@ import {
|
||||
Image,
|
||||
Button,
|
||||
Select,
|
||||
AspectRatio,
|
||||
Text,
|
||||
Card,
|
||||
LoadingOverlay,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { Apps } from 'tabler-icons-react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { ServiceTypeList } from '../../tools/types';
|
||||
import { AppShelfItemWrapper } from './AppShelfItemWrapper';
|
||||
|
||||
export function AddItemShelfButton(props: any) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
@@ -51,70 +47,16 @@ export function AddItemShelfButton(props: any) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function AddItemShelfItem(props: any) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
size="xl"
|
||||
radius="md"
|
||||
opened={props.opened || opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Add a service"
|
||||
>
|
||||
<AddAppShelfItemForm setOpened={setOpened} />
|
||||
</Modal>
|
||||
<AppShelfItemWrapper>
|
||||
<Card.Section>
|
||||
<Group position="center" mx="lg">
|
||||
<Text
|
||||
// TODO: #1 Remove this hack to get the text to be centered.
|
||||
ml={15}
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
alignContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
justifyItems: 'center',
|
||||
}}
|
||||
mt="sm"
|
||||
weight={500}
|
||||
>
|
||||
Add a service
|
||||
</Text>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<AspectRatio ratio={5 / 3} m="xl">
|
||||
<motion.i
|
||||
whileHover={{
|
||||
cursor: 'pointer',
|
||||
scale: 1.1,
|
||||
}}
|
||||
>
|
||||
<Apps style={{ cursor: 'pointer' }} onClick={() => setOpened(true)} size={60} />
|
||||
</motion.i>
|
||||
</AspectRatio>
|
||||
</Card.Section>
|
||||
</AppShelfItemWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchIcon(name: string, form: any) {
|
||||
fetch(
|
||||
`https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/${name
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase()}.png`
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
form.setFieldValue('icon', res.url);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Do nothing
|
||||
});
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
form.setFieldValue('icon', res.url);
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -126,9 +68,10 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
id: props.id ?? uuidv4(),
|
||||
type: props.type ?? 'Other',
|
||||
name: props.name ?? '',
|
||||
icon: props.icon ?? '',
|
||||
icon: props.icon ?? '/favicon.svg',
|
||||
url: props.url ?? '',
|
||||
apiKey: props.apiKey ?? (undefined as unknown as string),
|
||||
},
|
||||
@@ -136,15 +79,18 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
apiKey: () => null,
|
||||
// Validate icon with a regex
|
||||
icon: (value: string) => {
|
||||
if (!value.match(/^https?:\/\/.+\.(png|jpg|jpeg|gif)$/)) {
|
||||
// Regex to match everything that ends with and icon extension
|
||||
if (!value.match(/\.(png|jpg|jpeg|gif|svg)$/)) {
|
||||
return 'Please enter a valid icon URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// Validate url with a regex http/https
|
||||
url: (value: string) => {
|
||||
if (!value.match(/^https?:\/\/.+\/$/)) {
|
||||
return 'Please enter a valid URL (that ends with a /)';
|
||||
try {
|
||||
const _isValid = new URL(value);
|
||||
} catch (e) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
@@ -154,16 +100,24 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
return (
|
||||
<>
|
||||
<Center>
|
||||
<Image height={120} width={120} src={form.values.icon} alt="Placeholder" withPlaceholder />
|
||||
<Image
|
||||
height={120}
|
||||
width={120}
|
||||
fit="contain"
|
||||
src={form.values.icon}
|
||||
alt="Placeholder"
|
||||
withPlaceholder
|
||||
/>
|
||||
</Center>
|
||||
<form
|
||||
onSubmit={form.onSubmit(() => {
|
||||
// If service already exists, update it.
|
||||
if (config.services && config.services.find((s) => s.name === form.values.name)) {
|
||||
if (config.services && config.services.find((s) => s.id === form.values.id)) {
|
||||
setConfig({
|
||||
...config,
|
||||
// replace the found item by matching ID
|
||||
services: config.services.map((s) => {
|
||||
if (s.name === form.values.name) {
|
||||
if (s.id === form.values.id) {
|
||||
return {
|
||||
...form.values,
|
||||
};
|
||||
@@ -206,7 +160,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
<TextInput
|
||||
required
|
||||
label="Service url"
|
||||
placeholder="http://localhost:8989"
|
||||
placeholder="http://localhost:7575"
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SimpleGrid } from '@mantine/core';
|
||||
import AppShelf, { AppShelfItem } from './AppShelf';
|
||||
import AppShelf from './AppShelf';
|
||||
import { AppShelfItem } from './AppShelfItem';
|
||||
|
||||
export default {
|
||||
title: 'Item Shelf',
|
||||
|
||||
@@ -1,89 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Text, AspectRatio, Card, Image, useMantineTheme, Center, Grid } from '@mantine/core';
|
||||
import { Grid } from '@mantine/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { serviceItem } from '../../tools/types';
|
||||
import AppShelfMenu from './AppShelfMenu';
|
||||
|
||||
import { SortableAppShelfItem, AppShelfItem } from './AppShelfItem';
|
||||
|
||||
const AppShelf = (props: any) => {
|
||||
const { config } = useConfig();
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const { config, setConfig } = useConfig();
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
function handleDragStart(event: any) {
|
||||
const { active } = event;
|
||||
|
||||
setActiveId(active.id);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: any) {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over.id) {
|
||||
const newConfig = { ...config };
|
||||
const activeIndex = newConfig.services.findIndex((e) => e.id === active.id);
|
||||
const overIndex = newConfig.services.findIndex((e) => e.id === over.id);
|
||||
newConfig.services = arrayMove(newConfig.services, activeIndex, overIndex);
|
||||
setConfig(newConfig);
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid gutter="xl" align="center">
|
||||
{config.services.map((service) => (
|
||||
<Grid.Col span={6} xl={2} xs={4} sm={3} md={3}>
|
||||
<AppShelfItem key={service.name} service={service} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={config.services}>
|
||||
<Grid gutter="xl" align="center">
|
||||
{config.services.map((service) => (
|
||||
<Grid.Col key={service.id} span={6} xl={2} xs={4} sm={3} md={3}>
|
||||
<SortableAppShelfItem service={service} key={service.id} id={service.id} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</SortableContext>
|
||||
<DragOverlay
|
||||
style={{
|
||||
// Add a shadow to the drag overlay
|
||||
boxShadow: '0 0 10px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
{activeId ? (
|
||||
<AppShelfItem service={config.services.find((e) => e.id === activeId)} id={activeId} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export function AppShelfItem(props: any) {
|
||||
const { service }: { service: serviceItem } = props;
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<motion.div
|
||||
key={service.name}
|
||||
onHoverStart={() => {
|
||||
setHovering(true);
|
||||
}}
|
||||
onHoverEnd={() => {
|
||||
setHovering(false);
|
||||
}}
|
||||
>
|
||||
<Card withBorder radius="lg" shadow="md">
|
||||
<Card.Section>
|
||||
<Text mt="sm" align="center" lineClamp={1} weight={550}>
|
||||
{service.name}
|
||||
</Text>
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
right: 5,
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
animate={{
|
||||
opacity: hovering ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<AppShelfMenu service={service} />
|
||||
</motion.div>
|
||||
</Card.Section>
|
||||
<Center>
|
||||
<Card.Section>
|
||||
<AspectRatio
|
||||
ratio={3 / 5}
|
||||
m="xl"
|
||||
style={{
|
||||
width: 150,
|
||||
height: 90,
|
||||
}}
|
||||
>
|
||||
<motion.i
|
||||
whileHover={{
|
||||
cursor: 'pointer',
|
||||
scale: 1.1,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
width={80}
|
||||
height={80}
|
||||
src={service.icon}
|
||||
fit="contain"
|
||||
onClick={() => {
|
||||
window.open(service.url);
|
||||
}}
|
||||
/>
|
||||
</motion.i>
|
||||
</AspectRatio>
|
||||
</Card.Section>
|
||||
</Center>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppShelf;
|
||||
|
||||
123
src/components/AppShelf/AppShelfItem.tsx
Normal file
123
src/components/AppShelf/AppShelfItem.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Text,
|
||||
Card,
|
||||
Anchor,
|
||||
AspectRatio,
|
||||
Image,
|
||||
Center,
|
||||
createStyles,
|
||||
} from '@mantine/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { serviceItem } from '../../tools/types';
|
||||
import PingComponent from '../modules/ping/PingModule';
|
||||
import AppShelfMenu from './AppShelfMenu';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
item: {
|
||||
transition: 'box-shadow 150ms ease, transform 100ms ease',
|
||||
|
||||
'&:hover': {
|
||||
boxShadow: `${theme.shadows.md} !important`,
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export function SortableAppShelfItem(props: any) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: props.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<AppShelfItem service={props.service} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShelfItem(props: any) {
|
||||
const { service }: { service: serviceItem } = props;
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const { classes, theme } = useStyles();
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [0.9, 1.06, 1],
|
||||
rotate: [0, 5, 0],
|
||||
}}
|
||||
transition={{ duration: 0.6, type: 'spring', damping: 10, mass: 0.75, stiffness: 100 }}
|
||||
key={service.name}
|
||||
onHoverStart={() => {
|
||||
setHovering(true);
|
||||
}}
|
||||
onHoverEnd={() => {
|
||||
setHovering(false);
|
||||
}}
|
||||
>
|
||||
<Card withBorder radius="lg" shadow="md" className={classes.item}>
|
||||
<Card.Section>
|
||||
<Anchor
|
||||
target="_blank"
|
||||
href={service.url}
|
||||
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
>
|
||||
<Text mt="sm" align="center" lineClamp={1} weight={550}>
|
||||
{service.name}
|
||||
</Text>
|
||||
</Anchor>
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
right: 15,
|
||||
alignSelf: 'flex-end',
|
||||
}}
|
||||
animate={{
|
||||
opacity: hovering ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<AppShelfMenu service={service} />
|
||||
</motion.div>
|
||||
</Card.Section>
|
||||
<Center>
|
||||
<Card.Section>
|
||||
<AspectRatio
|
||||
ratio={3 / 5}
|
||||
m="xl"
|
||||
style={{
|
||||
width: 150,
|
||||
height: 90,
|
||||
}}
|
||||
>
|
||||
<motion.i
|
||||
whileHover={{
|
||||
cursor: 'pointer',
|
||||
scale: 1.1,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
width={80}
|
||||
height={80}
|
||||
src={service.icon}
|
||||
fit="contain"
|
||||
onClick={() => {
|
||||
window.open(service.url);
|
||||
}}
|
||||
/>
|
||||
</motion.i>
|
||||
</AspectRatio>
|
||||
<PingComponent url={service.url} />
|
||||
</Card.Section>
|
||||
</Center>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useMantineTheme, Card } from '@mantine/core';
|
||||
|
||||
export function AppShelfItemWrapper(props: any) {
|
||||
const { children, hovering } = props;
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
boxShadow: hovering ? '0px 0px 3px rgba(0, 0, 0, 0.5)' : '0px 0px 1px rgba(0, 0, 0, 0.5)',
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
|
||||
}}
|
||||
radius="md"
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Menu, Modal, Text } from '@mantine/core';
|
||||
import { Menu, Modal, Text, useMantineTheme } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useState } from 'react';
|
||||
import { Check, Edit, Trash } from 'tabler-icons-react';
|
||||
@@ -8,12 +8,13 @@ import { AddAppShelfItemForm } from './AddAppShelfItem';
|
||||
export default function AppShelfMenu(props: any) {
|
||||
const { service } = props;
|
||||
const { config, setConfig } = useConfig();
|
||||
const theme = useMantineTheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
size="xl"
|
||||
radius="lg"
|
||||
radius="md"
|
||||
opened={props.opened || opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Modify a service"
|
||||
@@ -21,6 +22,7 @@ export default function AppShelfMenu(props: any) {
|
||||
<AddAppShelfItemForm
|
||||
setOpened={setOpened}
|
||||
name={service.name}
|
||||
id={service.id}
|
||||
type={service.type}
|
||||
url={service.url}
|
||||
icon={service.icon}
|
||||
@@ -28,7 +30,16 @@ export default function AppShelfMenu(props: any) {
|
||||
message="Save service"
|
||||
/>
|
||||
</Modal>
|
||||
<Menu position="right">
|
||||
<Menu
|
||||
position="right"
|
||||
radius="md"
|
||||
styles={{
|
||||
body: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
<Menu.Item
|
||||
color="primary"
|
||||
@@ -44,7 +55,7 @@ export default function AppShelfMenu(props: any) {
|
||||
onClick={(e: any) => {
|
||||
setConfig({
|
||||
...config,
|
||||
services: config.services.filter((s) => s.name !== service.name),
|
||||
services: config.services.filter((s) => s.id !== service.id),
|
||||
});
|
||||
showNotification({
|
||||
autoClose: 5000,
|
||||
|
||||
2
src/components/AppShelf/index.ts
Normal file
2
src/components/AppShelf/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AppShelf } from './AppShelf';
|
||||
export * from './AppShelfItem';
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from 'next/router';
|
||||
import { setCookies } from 'cookies-next';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { Config } from '../../tools/types';
|
||||
import { migrateToIdConfig } from '../../tools/migrate';
|
||||
|
||||
function getIconColor(status: DropzoneStatus, theme: MantineTheme) {
|
||||
return status.accepted
|
||||
@@ -84,7 +85,8 @@ export default function LoadConfigComponent(props: any) {
|
||||
message: undefined,
|
||||
});
|
||||
setCookies('config-name', newConfig.name, { maxAge: 60 * 60 * 24 * 30 });
|
||||
setConfig(newConfig);
|
||||
const migratedConfig = migrateToIdConfig(newConfig);
|
||||
setConfig(migratedConfig);
|
||||
});
|
||||
}}
|
||||
accept={['application/json']}
|
||||
|
||||
@@ -5,34 +5,25 @@ import { useConfig } from '../../tools/state';
|
||||
export default function ModuleEnabler(props: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const modules = Object.values(Modules).map((module) => module);
|
||||
const enabledModules = config.settings.enabledModules ?? [];
|
||||
modules.filter((module) => enabledModules.includes(module.title));
|
||||
return (
|
||||
<Group direction="column">
|
||||
{modules.map((module) => (
|
||||
<Switch
|
||||
key={module.title}
|
||||
size="md"
|
||||
checked={enabledModules.includes(module.title)}
|
||||
checked={config.modules?.[module.title]?.enabled ?? false}
|
||||
label={`Enable ${module.title} module`}
|
||||
onChange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
enabledModules: [...enabledModules, module.title],
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules?.[module.title],
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
enabledModules: enabledModules.filter((m) => m !== module.title),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,19 +2,15 @@ import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Modal,
|
||||
Switch,
|
||||
Title,
|
||||
Text,
|
||||
Tooltip,
|
||||
SegmentedControl,
|
||||
Indicator,
|
||||
Alert,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertCircle, Settings as SettingsIcon } from 'tabler-icons-react';
|
||||
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
|
||||
import { useState } from 'react';
|
||||
import { Settings as SettingsIcon } from 'tabler-icons-react';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
|
||||
import ConfigChanger from '../Config/ConfigChanger';
|
||||
@@ -40,14 +36,6 @@ function SettingsMenu(props: any) {
|
||||
|
||||
return (
|
||||
<Group direction="column" grow>
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Update available"
|
||||
radius="lg"
|
||||
hidden={current === latest}
|
||||
>
|
||||
Version {latest} is available. Current: {current}
|
||||
</Alert>
|
||||
<Group grow direction="column" spacing={0}>
|
||||
<Text>Search engine</Text>
|
||||
<SegmentedControl
|
||||
@@ -90,22 +78,6 @@ function SettingsMenu(props: any) {
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group direction="column">
|
||||
<Switch
|
||||
size="md"
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
searchBar: e.currentTarget.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
checked={config.settings.searchBar}
|
||||
label="Enable search bar"
|
||||
/>
|
||||
</Group>
|
||||
<ModuleEnabler />
|
||||
<ColorSchemeSwitch />
|
||||
<ConfigChanger />
|
||||
@@ -125,20 +97,7 @@ function SettingsMenu(props: any) {
|
||||
}
|
||||
|
||||
export function SettingsMenuButton(props: any) {
|
||||
const [update, setUpdate] = useState(false);
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState(CURRENT_VERSION);
|
||||
useEffect(() => {
|
||||
// Fetch Data here when component first mounted
|
||||
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
|
||||
res.json().then((data) => {
|
||||
setLatestVersion(data.tag_name);
|
||||
if (data.tag_name !== CURRENT_VERSION) {
|
||||
setUpdate(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -148,7 +107,7 @@ export function SettingsMenuButton(props: any) {
|
||||
opened={props.opened || opened}
|
||||
onClose={() => setOpened(false)}
|
||||
>
|
||||
<SettingsMenu current={CURRENT_VERSION} latest={latestVersion} />
|
||||
<SettingsMenu />
|
||||
</Modal>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -159,14 +118,7 @@ export function SettingsMenuButton(props: any) {
|
||||
onClick={() => setOpened(true)}
|
||||
>
|
||||
<Tooltip label="Settings">
|
||||
<Indicator
|
||||
size={12}
|
||||
disabled={CURRENT_VERSION === latestVersion}
|
||||
offset={-3}
|
||||
position="top-end"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Indicator>
|
||||
<SettingsIcon />
|
||||
</Tooltip>
|
||||
</ActionIcon>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Aside as MantineAside, Group } from '@mantine/core';
|
||||
import { DateModule } from '../modules';
|
||||
import { CalendarModule } from '../modules/calendar/CalendarModule';
|
||||
import ModuleWrapper from '../modules/moduleWrapper';
|
||||
import { WeatherModule, DateModule, CalendarModule } from '../modules';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
|
||||
export default function Aside(props: any) {
|
||||
return (
|
||||
@@ -18,6 +17,7 @@ export default function Aside(props: any) {
|
||||
<Group mt="sm" grow direction="column">
|
||||
<ModuleWrapper module={CalendarModule} />
|
||||
<ModuleWrapper module={DateModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
</Group>
|
||||
</MantineAside>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
createStyles,
|
||||
Anchor,
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
Group,
|
||||
ActionIcon,
|
||||
Footer as FooterComponent,
|
||||
Alert,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { BrandGithub } from 'tabler-icons-react';
|
||||
import { AlertCircle, BrandGithub } from 'tabler-icons-react';
|
||||
import { CURRENT_VERSION, REPO_URL } from '../../../data/constants';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
footer: {
|
||||
@@ -40,6 +43,8 @@ interface FooterCenteredProps {
|
||||
}
|
||||
|
||||
export function Footer({ links }: FooterCenteredProps) {
|
||||
const [update, setUpdate] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
const { classes } = useStyles();
|
||||
const items = links.map((link) => (
|
||||
<Anchor<'a'>
|
||||
@@ -54,27 +59,87 @@ export function Footer({ links }: FooterCenteredProps) {
|
||||
</Anchor>
|
||||
));
|
||||
|
||||
return (
|
||||
<FooterComponent p={5} height="auto" style={{ border: 'none', position: 'fixed', bottom: 0, right: 0 }}>
|
||||
<Group position="right" mr="xs" mb="xs">
|
||||
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
|
||||
<BrandGithub size={18} />
|
||||
</ActionIcon>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '0.90rem',
|
||||
textAlign: 'center',
|
||||
color: '#a0aec0',
|
||||
}}
|
||||
>
|
||||
Made with ❤️ by @
|
||||
<Anchor
|
||||
href="https://github.com/ajnart"
|
||||
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
const [latestVersion, setLatestVersion] = useState(CURRENT_VERSION);
|
||||
const [isOpen, setOpen] = useState(true);
|
||||
useEffect(() => {
|
||||
// Fetch Data here when component first mounted
|
||||
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
|
||||
res.json().then((data) => {
|
||||
setLatestVersion(data.tag_name);
|
||||
if (data.tag_name !== CURRENT_VERSION) {
|
||||
setUpdate(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FooterComponent
|
||||
p={5}
|
||||
height="auto"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
clear: 'both',
|
||||
position: 'fixed',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
}}
|
||||
>
|
||||
<Group position="apart" direction="row" style={{ alignItems: 'end' }} mr="xs" mb="xs">
|
||||
<Group position="left">
|
||||
<Alert
|
||||
// onClick open latest release page
|
||||
onClose={() => setOpen(false)}
|
||||
icon={<AlertCircle size={16} />}
|
||||
title={`Updated version: ${latestVersion} is available. Current version: ${CURRENT_VERSION}`}
|
||||
withCloseButton
|
||||
radius="lg"
|
||||
hidden={CURRENT_VERSION === latestVersion || !isOpen}
|
||||
variant="outline"
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white',
|
||||
},
|
||||
|
||||
closeButton: {
|
||||
marginLeft: '5px',
|
||||
},
|
||||
}}
|
||||
children={undefined}
|
||||
/>
|
||||
</Group>
|
||||
<Group position="right">
|
||||
<Group spacing={0}>
|
||||
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
|
||||
<BrandGithub size={18} />
|
||||
</ActionIcon>
|
||||
<Text
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: '0.90rem',
|
||||
color: 'gray',
|
||||
}}
|
||||
>
|
||||
{CURRENT_VERSION}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '0.90rem',
|
||||
textAlign: 'center',
|
||||
color: '#a0aec0',
|
||||
}}
|
||||
>
|
||||
ajnart
|
||||
</Anchor>
|
||||
</Text>
|
||||
Made with ❤️ by @
|
||||
<Anchor
|
||||
href="https://github.com/ajnart"
|
||||
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
|
||||
>
|
||||
ajnart
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</FooterComponent>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createStyles, Header as Head, Group, Box } from '@mantine/core';
|
||||
import { Logo } from './Logo';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import SearchBar from '../modules/search/SearchModule';
|
||||
import { AddItemShelfButton } from '../AppShelf/AddAppShelfItem';
|
||||
import { SettingsMenuButton } from '../Settings/SettingsMenu';
|
||||
|
||||
|
||||
@@ -10,11 +10,7 @@ const useStyles = createStyles((theme) => ({
|
||||
export default function Layout({ children, style }: any) {
|
||||
const { classes, cx } = useStyles();
|
||||
return (
|
||||
<AppShell
|
||||
aside={<Aside />}
|
||||
header={<Header />}
|
||||
footer={<Footer links={[]} />}
|
||||
>
|
||||
<AppShell aside={<Aside />} header={<Header />} footer={<Footer links={[]} />}>
|
||||
<main
|
||||
className={cx(classes.main)}
|
||||
style={{
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
import { Group, Image, Text } from '@mantine/core';
|
||||
import { NextLink } from '@mantine/next';
|
||||
import * as React from 'react';
|
||||
import { CURRENT_VERSION } from '../../../data/constants';
|
||||
|
||||
export function Logo({ style }: any) {
|
||||
return (
|
||||
<Group>
|
||||
<Group spacing="xs">
|
||||
<Image
|
||||
width={50}
|
||||
src="/imgs/logo.png"
|
||||
style={{
|
||||
position: 'relative',
|
||||
left: 15,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
sx={style}
|
||||
weight="bold"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'red', to: 'orange', deg: 145 }}
|
||||
>
|
||||
Homarr
|
||||
</Text>
|
||||
<Text
|
||||
<NextLink
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
position: 'relative',
|
||||
left: -14,
|
||||
bottom: -2,
|
||||
color: 'gray',
|
||||
fontStyle: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
alignSelf: 'center',
|
||||
alignContent: 'center',
|
||||
}}
|
||||
href="/"
|
||||
>
|
||||
{CURRENT_VERSION}
|
||||
</Text>
|
||||
<Text
|
||||
sx={style}
|
||||
weight="bold"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'red', to: 'orange', deg: 145 }}
|
||||
>
|
||||
Homarr
|
||||
</Text>
|
||||
</NextLink>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group, Navbar as MantineNavbar } from '@mantine/core';
|
||||
import { DateModule } from '../modules/date/DateModule';
|
||||
import ModuleWrapper from '../modules/moduleWrapper';
|
||||
import { WeatherModule, DateModule } from '../modules';
|
||||
import { ModuleWrapper } from '../modules/moduleWrapper';
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
@@ -16,6 +16,8 @@ export default function Navbar() {
|
||||
>
|
||||
<Group mt="sm" direction="column" align="center">
|
||||
<ModuleWrapper module={DateModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
<ModuleWrapper module={WeatherModule} />
|
||||
</Group>
|
||||
</MantineNavbar>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable react/no-children-prop */
|
||||
import { Popover, Box, ScrollArea, Divider, Indicator } from '@mantine/core';
|
||||
import { Popover, Box, ScrollArea, Divider, Indicator, useMantineTheme } from '@mantine/core';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Calendar } from '@mantine/dates';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
@@ -32,40 +32,42 @@ export default function CalendarComponent(props: any) {
|
||||
const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0);
|
||||
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
|
||||
if (sonarrService && sonarrService.apiKey) {
|
||||
fetch(
|
||||
`${sonarrService?.url}api/calendar?apikey=${sonarrService?.apiKey}&end=${nextMonth}`
|
||||
).then((response) => {
|
||||
response.ok &&
|
||||
response.json().then((data) => {
|
||||
setSonarrMedias(data);
|
||||
showNotification({
|
||||
title: 'Sonarr',
|
||||
icon: <Check />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: `Loaded ${data.length} releases`,
|
||||
const baseUrl = new URL(sonarrService.url).origin;
|
||||
fetch(`${baseUrl}/api/calendar?apikey=${sonarrService?.apiKey}&end=${nextMonth}`).then(
|
||||
(response) => {
|
||||
response.ok &&
|
||||
response.json().then((data) => {
|
||||
setSonarrMedias(data);
|
||||
showNotification({
|
||||
title: 'Sonarr',
|
||||
icon: <Check />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: `Loaded ${data.length} releases`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
if (radarrService && radarrService.apiKey) {
|
||||
fetch(
|
||||
`${radarrService?.url}api/v3/calendar?apikey=${radarrService?.apiKey}&end=${nextMonth}`
|
||||
).then((response) => {
|
||||
response.ok &&
|
||||
response.json().then((data) => {
|
||||
setRadarrMedias(data);
|
||||
showNotification({
|
||||
title: 'Radarr',
|
||||
icon: <Check />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: `Loaded ${data.length} releases`,
|
||||
const baseUrl = new URL(radarrService.url).origin;
|
||||
fetch(`${baseUrl}/api/v3/calendar?apikey=${radarrService?.apiKey}&end=${nextMonth}`).then(
|
||||
(response) => {
|
||||
response.ok &&
|
||||
response.json().then((data) => {
|
||||
setRadarrMedias(data);
|
||||
showNotification({
|
||||
title: 'Radarr',
|
||||
icon: <Check />,
|
||||
color: 'green',
|
||||
autoClose: 1500,
|
||||
radius: 'md',
|
||||
message: `Loaded ${data.length} releases`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [config.services]);
|
||||
|
||||
@@ -93,6 +95,7 @@ function DayComponent(props: any) {
|
||||
radarrmedias,
|
||||
}: { renderdate: Date; sonarrmedias: []; radarrmedias: [] } = props;
|
||||
const [opened, setOpened] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const day = renderdate.getDate();
|
||||
// Itterate over the medias and filter the ones that are on the same day
|
||||
@@ -126,8 +129,7 @@ function DayComponent(props: any) {
|
||||
width={700}
|
||||
onClose={() => setOpened(false)}
|
||||
opened={opened}
|
||||
// TODO: Fix this !! WTF ?
|
||||
target={` ${day}`}
|
||||
target={day}
|
||||
>
|
||||
<ScrollArea style={{ height: 400 }}>
|
||||
{sonarrFiltered.map((media: any, index: number) => (
|
||||
|
||||
@@ -30,7 +30,7 @@ function MediaDisplay(props: { media: IMedia }) {
|
||||
})}
|
||||
>
|
||||
<Group direction="column">
|
||||
<Group>
|
||||
<Group noWrap>
|
||||
<Title order={3}>{media.title}</Title>
|
||||
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
|
||||
<ActionIcon>
|
||||
@@ -48,7 +48,9 @@ function MediaDisplay(props: { media: IMedia }) {
|
||||
Season {media.seasonNumber} episode {media.episodeNumber}
|
||||
</Text>
|
||||
)}
|
||||
<Text align="justify">{media.overview}</Text>
|
||||
<Text lineClamp={12} align="justify">
|
||||
{media.overview}
|
||||
</Text>
|
||||
</Group>
|
||||
{/*Add the genres at the bottom of the poster*/}
|
||||
<Group>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Group, Text, Title } from '@mantine/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Clock } from 'tabler-icons-react';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
export const DateModule: IModule = {
|
||||
@@ -9,33 +10,31 @@ export const DateModule: IModule = {
|
||||
description: 'Show the current time and date in a card',
|
||||
icon: Clock,
|
||||
component: DateComponent,
|
||||
options: {
|
||||
full: {
|
||||
name: 'Display full time (24-hour)',
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function DateComponent(props: any) {
|
||||
const [date, setDate] = useState(new Date());
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
|
||||
const { config } = useConfig();
|
||||
const isFullTime = config?.modules?.[DateModule.title]?.options?.full?.value ?? false;
|
||||
const formatString = isFullTime ? 'HH:mm' : 'h:mm A';
|
||||
// Change date on minute change
|
||||
// Note: Using 10 000ms instead of 1000ms to chill a little :)
|
||||
useEffect(() => {
|
||||
setInterval(() => {
|
||||
setDate(new Date());
|
||||
}, 10000);
|
||||
}, 1000 * 60);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Group p="sm" direction="column">
|
||||
<Title>
|
||||
{hours < 10 ? `0${hours}` : hours}:{minutes < 10 ? `0${minutes}` : minutes}
|
||||
</Title>
|
||||
<Text size="xl">
|
||||
{
|
||||
// Use dayjs to format the date
|
||||
// https://day.js.org/en/getting-started/installation/
|
||||
dayjs(date).format('dddd, MMMM D')
|
||||
}
|
||||
</Text>
|
||||
<Title>{dayjs(date).format(formatString)}</Title>
|
||||
<Text size="xl">{dayjs(date).format('dddd, MMMM D')}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './date';
|
||||
export * from './calendar';
|
||||
export * from './search';
|
||||
export * from './ping';
|
||||
export * from './weather';
|
||||
|
||||
@@ -1,19 +1,87 @@
|
||||
import { Card, useMantineTheme } from '@mantine/core';
|
||||
import { Card, Menu, Switch, useMantineTheme } from '@mantine/core';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { IModule } from './modules';
|
||||
|
||||
export default function ModuleWrapper(props: any) {
|
||||
export function ModuleWrapper(props: any) {
|
||||
const { module }: { module: IModule } = props;
|
||||
const { config } = useConfig();
|
||||
const enabledModules = config.settings.enabledModules ?? [];
|
||||
const { config, setConfig } = useConfig();
|
||||
const enabledModules = config.modules ?? {};
|
||||
// Remove 'Module' from enabled modules titles
|
||||
const isShown = enabledModules.includes(module.title);
|
||||
const isShown = enabledModules[module.title]?.enabled ?? false;
|
||||
const theme = useMantineTheme();
|
||||
const items: JSX.Element[] = [];
|
||||
if (module.options) {
|
||||
const keys = Object.keys(module.options);
|
||||
const values = Object.values(module.options);
|
||||
// Get the value and the name of the option
|
||||
const types = values.map((v) => typeof v.value);
|
||||
// Loop over all the types with a for each loop
|
||||
types.forEach((type, index) => {
|
||||
const optionName = `${module.title}.${keys[index]}`;
|
||||
const moduleInConfig = config.modules?.[module.title];
|
||||
// TODO: Add support for other types
|
||||
if (type === 'boolean') {
|
||||
items.push(
|
||||
<Switch
|
||||
defaultChecked={
|
||||
// Set default checked to the value of the option if it exists
|
||||
moduleInConfig?.options?.[keys[index]]?.value ?? false
|
||||
}
|
||||
key={keys[index]}
|
||||
onClick={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
modules: {
|
||||
...config.modules,
|
||||
[module.title]: {
|
||||
...config.modules[module.title],
|
||||
options: {
|
||||
...config.modules[module.title].options,
|
||||
[keys[index]]: {
|
||||
...config.modules[module.title].options?.[keys[index]],
|
||||
value: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
label={values[index].name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!isShown) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Card hidden={!isShown} mx="sm" withBorder radius="lg" shadow="sm">
|
||||
{module.options && (
|
||||
<Menu
|
||||
size="md"
|
||||
shadow="xl"
|
||||
closeOnItemClick={false}
|
||||
radius="md"
|
||||
position="left"
|
||||
styles={{
|
||||
root: {
|
||||
position: 'absolute',
|
||||
top: 15,
|
||||
right: 15,
|
||||
},
|
||||
body: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.key}>{item}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
)}
|
||||
<module.component />
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -7,5 +7,14 @@ export interface IModule {
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
component: React.ComponentType;
|
||||
props?: any;
|
||||
options?: Option;
|
||||
}
|
||||
|
||||
interface Option {
|
||||
[x: string]: OptionValues;
|
||||
}
|
||||
|
||||
export interface OptionValues {
|
||||
name: string;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
16
src/components/modules/ping/PingModule.story.tsx
Normal file
16
src/components/modules/ping/PingModule.story.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
import PingComponent from './PingModule';
|
||||
|
||||
export default {
|
||||
title: 'Modules/Search bar',
|
||||
};
|
||||
|
||||
const service: serviceItem = {
|
||||
id: '1',
|
||||
type: 'Other',
|
||||
name: 'YouTube',
|
||||
icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png',
|
||||
url: 'https://youtube.com/',
|
||||
};
|
||||
|
||||
export const Default = (args: any) => <PingComponent service={service} />;
|
||||
60
src/components/modules/ping/PingModule.tsx
Normal file
60
src/components/modules/ping/PingModule.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Indicator, Tooltip } from '@mantine/core';
|
||||
import axios from 'axios';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plug } from 'tabler-icons-react';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
export const PingModule: IModule = {
|
||||
title: 'Ping Services',
|
||||
description: 'Pings your services and shows their status as an indicator',
|
||||
icon: Plug,
|
||||
component: PingComponent,
|
||||
};
|
||||
|
||||
export default function PingComponent(props: any) {
|
||||
type State = 'loading' | 'down' | 'online';
|
||||
const { config } = useConfig();
|
||||
|
||||
const { url }: { url: string } = props;
|
||||
const [isOnline, setOnline] = useState<State>('loading');
|
||||
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
|
||||
useEffect(() => {
|
||||
if (!exists) {
|
||||
return;
|
||||
}
|
||||
axios
|
||||
.get('/api/modules/ping', { params: { url } })
|
||||
.then(() => {
|
||||
setOnline('online');
|
||||
})
|
||||
.catch(() => {
|
||||
setOnline('down');
|
||||
});
|
||||
}, []);
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
radius="lg"
|
||||
style={{ position: 'absolute', bottom: 20, right: 20 }}
|
||||
label={isOnline === 'loading' ? 'Loading...' : isOnline === 'online' ? 'Online' : 'Offline'}
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: isOnline === 'online' ? [1, 0.8, 1] : 1,
|
||||
}}
|
||||
transition={{ repeat: Infinity, duration: 2.5, ease: 'easeInOut' }}
|
||||
>
|
||||
<Indicator
|
||||
size={13}
|
||||
color={isOnline === 'online' ? 'green' : isOnline === 'down' ? 'red' : 'yellow'}
|
||||
>
|
||||
{null}
|
||||
</Indicator>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
1
src/components/modules/ping/index.ts
Normal file
1
src/components/modules/ping/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PingModule } from './PingModule';
|
||||
@@ -1,4 +1,4 @@
|
||||
import SearchBar from './SearchBar';
|
||||
import SearchBar from './SearchModule';
|
||||
|
||||
export default {
|
||||
title: 'Search bar',
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TextInput, Kbd, createStyles, useMantineTheme, Text, Popover } from '@mantine/core';
|
||||
import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core';
|
||||
import { useForm, useHotkeys } from '@mantine/hooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Search, BrandYoutube, Download } from 'tabler-icons-react';
|
||||
import { useConfig } from '../../tools/state';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
@@ -14,16 +15,22 @@ const useStyles = createStyles((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export const SearchModule: IModule = {
|
||||
title: 'Search Bar',
|
||||
description: 'Show the current time and date in a card',
|
||||
icon: Search,
|
||||
component: SearchBar,
|
||||
};
|
||||
|
||||
export default function SearchBar(props: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [icon, setIcon] = useState(<Search />);
|
||||
const queryUrl = config.settings.searchUrl || 'https://www.google.com/search?q=';
|
||||
const textInput: any = useRef(null);
|
||||
useHotkeys([['ctrl+K', () => textInput.current.focus()]]);
|
||||
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
|
||||
const textInput = useRef<HTMLInputElement>();
|
||||
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
|
||||
|
||||
const { classes, cx } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
const rightSection = (
|
||||
<div className={classes.hide}>
|
||||
<Kbd>Ctrl</Kbd>
|
||||
@@ -38,7 +45,11 @@ export default function SearchBar(props: any) {
|
||||
},
|
||||
});
|
||||
|
||||
if (config.settings.searchBar === false) {
|
||||
// If enabled modules doesn't contain the module, return null
|
||||
// If module in enabled
|
||||
|
||||
const exists = config.modules?.[SearchModule.title]?.enabled ?? false;
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,17 +69,19 @@ export default function SearchBar(props: any) {
|
||||
}
|
||||
}}
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
// Find if query is prefixed by !yt or !t
|
||||
const query = values.query.trim();
|
||||
const isYoutube = query.startsWith('!yt');
|
||||
const isTorrent = query.startsWith('!t');
|
||||
if (isYoutube) {
|
||||
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
|
||||
} else if (isTorrent) {
|
||||
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
|
||||
} else {
|
||||
window.open(`${queryUrl}${values.query}`);
|
||||
}
|
||||
form.setValues({ query: '' });
|
||||
setTimeout(() => {
|
||||
if (isYoutube) {
|
||||
window.open(`https://www.youtube.com/results?search_query=${query.substring(3)}`);
|
||||
} else if (isTorrent) {
|
||||
window.open(`https://bitsearch.to/search?q=${query.substring(3)}`);
|
||||
} else {
|
||||
window.open(`${queryUrl}${values.query}`);
|
||||
}
|
||||
}, 20);
|
||||
})}
|
||||
>
|
||||
<Popover
|
||||
1
src/components/modules/search/index.ts
Normal file
1
src/components/modules/search/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SearchModule } from './SearchModule';
|
||||
41
src/components/modules/weather/WeatherInterface.ts
Normal file
41
src/components/modules/weather/WeatherInterface.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// To parse this data:
|
||||
//
|
||||
// import { Convert, WeatherResponse } from "./file";
|
||||
//
|
||||
// const weatherResponse = Convert.toWeatherResponse(json);
|
||||
//
|
||||
// These functions will throw an error if the JSON doesn't
|
||||
// match the expected interface, even if the JSON is valid.
|
||||
|
||||
export interface WeatherResponse {
|
||||
current_weather: CurrentWeather;
|
||||
utc_offset_seconds: number;
|
||||
latitude: number;
|
||||
elevation: number;
|
||||
longitude: number;
|
||||
generationtime_ms: number;
|
||||
daily_units: DailyUnits;
|
||||
daily: Daily;
|
||||
}
|
||||
|
||||
export interface CurrentWeather {
|
||||
winddirection: number;
|
||||
windspeed: number;
|
||||
time: string;
|
||||
weathercode: number;
|
||||
temperature: number;
|
||||
}
|
||||
|
||||
export interface Daily {
|
||||
temperature_2m_max: number[];
|
||||
time: Date[];
|
||||
temperature_2m_min: number[];
|
||||
weathercode: number[];
|
||||
}
|
||||
|
||||
export interface DailyUnits {
|
||||
temperature_2m_max: string;
|
||||
temperature_2m_min: string;
|
||||
time: string;
|
||||
weathercode: string;
|
||||
}
|
||||
172
src/components/modules/weather/WeatherModule.tsx
Normal file
172
src/components/modules/weather/WeatherModule.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Group, Space, Title, Tooltip } from '@mantine/core';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
CloudStorm,
|
||||
QuestionMark,
|
||||
Snowflake,
|
||||
Sun,
|
||||
} from 'tabler-icons-react';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import { WeatherResponse } from './WeatherInterface';
|
||||
|
||||
export const WeatherModule: IModule = {
|
||||
title: 'Weather (beta)',
|
||||
description: 'Look up the current weather in your location',
|
||||
icon: Sun,
|
||||
component: WeatherComponent,
|
||||
options: {
|
||||
freedomunit: {
|
||||
name: 'Display in Fahrenheit',
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 0 Clear sky
|
||||
// 1, 2, 3 Mainly clear, partly cloudy, and overcast
|
||||
// 45, 48 Fog and depositing rime fog
|
||||
// 51, 53, 55 Drizzle: Light, moderate, and dense intensity
|
||||
// 56, 57 Freezing Drizzle: Light and dense intensity
|
||||
// 61, 63, 65 Rain: Slight, moderate and heavy intensity
|
||||
// 66, 67 Freezing Rain: Light and heavy intensity
|
||||
// 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity
|
||||
// 77 Snow grains
|
||||
// 80, 81, 82 Rain showers: Slight, moderate, and violent
|
||||
// 85, 86Snow showers slight and heavy
|
||||
// 95 *Thunderstorm: Slight or moderate
|
||||
// 96, 99 *Thunderstorm with slight and heavy hail
|
||||
export function WeatherIcon(props: any) {
|
||||
const { code } = props;
|
||||
let data: { icon: any; name: string };
|
||||
switch (code) {
|
||||
case 0: {
|
||||
data = { icon: Sun, name: 'Clear' };
|
||||
break;
|
||||
}
|
||||
case 1:
|
||||
case 2:
|
||||
case 3: {
|
||||
data = { icon: Cloud, name: 'Mainly clear' };
|
||||
break;
|
||||
}
|
||||
case 45:
|
||||
case 48: {
|
||||
data = { icon: CloudFog, name: 'Fog' };
|
||||
break;
|
||||
}
|
||||
case 51:
|
||||
case 53:
|
||||
case 55: {
|
||||
data = { icon: Cloud, name: 'Drizzle' };
|
||||
break;
|
||||
}
|
||||
case 56:
|
||||
case 57: {
|
||||
data = { icon: Snowflake, name: 'Freezing drizzle' };
|
||||
break;
|
||||
}
|
||||
case 61:
|
||||
case 63:
|
||||
case 65: {
|
||||
data = { icon: CloudRain, name: 'Rain' };
|
||||
break;
|
||||
}
|
||||
case 66:
|
||||
case 67: {
|
||||
data = { icon: CloudRain, name: 'Freezing rain' };
|
||||
break;
|
||||
}
|
||||
case 71:
|
||||
case 73:
|
||||
case 75: {
|
||||
data = { icon: CloudSnow, name: 'Snow fall' };
|
||||
break;
|
||||
}
|
||||
case 77: {
|
||||
data = { icon: CloudSnow, name: 'Snow grains' };
|
||||
break;
|
||||
}
|
||||
case 80:
|
||||
case 81:
|
||||
case 82: {
|
||||
data = { icon: CloudRain, name: 'Rain showers' };
|
||||
|
||||
break;
|
||||
}
|
||||
case 85:
|
||||
case 86: {
|
||||
data = { icon: CloudSnow, name: 'Snow showers' };
|
||||
break;
|
||||
}
|
||||
case 95: {
|
||||
data = { icon: CloudStorm, name: 'Thunderstorm' };
|
||||
break;
|
||||
}
|
||||
case 96:
|
||||
case 99: {
|
||||
data = { icon: CloudStorm, name: 'Thunderstorm with hail' };
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
data = { icon: QuestionMark, name: 'Unknown' };
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Tooltip label={data.name}>
|
||||
<data.icon size={50} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WeatherComponent(props: any) {
|
||||
// Get location from browser
|
||||
const [location, setLocation] = useState({ lat: 0, lng: 0 });
|
||||
const { config } = useConfig();
|
||||
const [weather, setWeather] = useState({} as WeatherResponse);
|
||||
const isFahrenheit: boolean =
|
||||
config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value ?? false;
|
||||
|
||||
if ('geolocation' in navigator && location.lat === 0 && location.lng === 0) {
|
||||
navigator.geolocation.getCurrentPosition((position) => {
|
||||
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude });
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.get(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lng}&daily=weathercode,temperature_2m_max,temperature_2m_min¤t_weather=true&timezone=Europe%2FLondon`
|
||||
)
|
||||
.then((res) => {
|
||||
setWeather(res.data);
|
||||
});
|
||||
}, []);
|
||||
if (!weather.current_weather) {
|
||||
return null;
|
||||
}
|
||||
function usePerferedUnit(value: number): string {
|
||||
return isFahrenheit ? `${(value * (9 / 5)).toFixed(1)}°F` : `${value.toFixed(1)}°C`;
|
||||
}
|
||||
return (
|
||||
<Group position="left" direction="column">
|
||||
<Title>{usePerferedUnit(weather.current_weather.temperature)}</Title>
|
||||
<Group spacing={0}>
|
||||
<WeatherIcon code={weather.current_weather.weathercode} />
|
||||
<Space mx="sm" />
|
||||
<span>{usePerferedUnit(weather.daily.temperature_2m_max[0])}</span>
|
||||
<ArrowUpRight size={16} style={{ right: 15 }} />
|
||||
<Space mx="sm" />
|
||||
<span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
|
||||
<ArrowDownRight size={16} />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
1
src/components/modules/weather/index.ts
Normal file
1
src/components/modules/weather/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WeatherModule } from './WeatherModule';
|
||||
94
src/pages/404.tsx
Normal file
94
src/pages/404.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
createStyles,
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { NextLink } from '@mantine/next';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
paddingTop: 80,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
|
||||
inner: {
|
||||
position: 'relative',
|
||||
},
|
||||
|
||||
image: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
zIndex: 0,
|
||||
opacity: 0.75,
|
||||
},
|
||||
|
||||
content: {
|
||||
paddingTop: 220,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
paddingTop: 120,
|
||||
},
|
||||
},
|
||||
|
||||
title: {
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
textAlign: 'center',
|
||||
fontWeight: 900,
|
||||
fontSize: 38,
|
||||
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
fontSize: 32,
|
||||
},
|
||||
},
|
||||
|
||||
description: {
|
||||
maxWidth: 540,
|
||||
margin: 'auto',
|
||||
marginTop: theme.spacing.xl,
|
||||
marginBottom: theme.spacing.xl * 1.5,
|
||||
},
|
||||
}));
|
||||
|
||||
export function Illustration(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 362 145" {...props}>
|
||||
<path
|
||||
fill={theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0]}
|
||||
d="M62.6 142c-2.133 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2L58.2 4c.8-1.333 2.067-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .667.533 1 1.267 1 2.2v21.2c0 .933-.333 1.733-1 2.4-.667.533-1.467.8-2.4.8H93v20.8c0 2.133-1.067 3.2-3.2 3.2H62.6zM33 90.4h26.4V51.2L33 90.4zM181.67 144.6c-7.333 0-14.333-1.333-21-4-6.666-2.667-12.866-6.733-18.6-12.2-5.733-5.467-10.266-13-13.6-22.6-3.333-9.6-5-20.667-5-33.2 0-12.533 1.667-23.6 5-33.2 3.334-9.6 7.867-17.133 13.6-22.6 5.734-5.467 11.934-9.533 18.6-12.2 6.667-2.8 13.667-4.2 21-4.2 7.467 0 14.534 1.4 21.2 4.2 6.667 2.667 12.8 6.733 18.4 12.2 5.734 5.467 10.267 13 13.6 22.6 3.334 9.6 5 20.667 5 33.2 0 12.533-1.666 23.6-5 33.2-3.333 9.6-7.866 17.133-13.6 22.6-5.6 5.467-11.733 9.533-18.4 12.2-6.666 2.667-13.733 4-21.2 4zm0-31c9.067 0 15.6-3.733 19.6-11.2 4.134-7.6 6.2-17.533 6.2-29.8s-2.066-22.2-6.2-29.8c-4.133-7.6-10.666-11.4-19.6-11.4-8.933 0-15.466 3.8-19.6 11.4-4 7.6-6 17.533-6 29.8s2 22.2 6 29.8c4.134 7.467 10.667 11.2 19.6 11.2zM316.116 142c-2.134 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2l56.6-84.6c.8-1.333 2.066-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .666.533 1 1.267 1 2.2v21.2c0 .933-.334 1.733-1 2.4-.667.533-1.467.8-2.4.8h-11.2v20.8c0 2.133-1.067 3.2-3.2 3.2h-27.2zm-29.6-51.6h26.4V51.2l-26.4 39.2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NothingFoundBackground() {
|
||||
const { classes } = useStyles();
|
||||
|
||||
return (
|
||||
<Container className={classes.root}>
|
||||
<div className={classes.inner}>
|
||||
<Illustration className={classes.image} />
|
||||
<div className={classes.content}>
|
||||
<Title className={classes.title}>Config not found</Title>
|
||||
<Text color="dimmed" size="lg" align="center" className={classes.description}>
|
||||
The config you are trying to access does not exist. Please check the URL and try again.
|
||||
</Text>
|
||||
<Group position="center">
|
||||
<NextLink href="/">
|
||||
<Button size="md">Take me back to home page</Button>
|
||||
</NextLink>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
54
src/pages/[slug].tsx
Normal file
54
src/pages/[slug].tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { useEffect } from 'react';
|
||||
import AppShelf from '../components/AppShelf/AppShelf';
|
||||
import LoadConfigComponent from '../components/Config/LoadConfig';
|
||||
import { Config } from '../tools/types';
|
||||
import { useConfig } from '../tools/state';
|
||||
|
||||
export async function getServerSideProps(
|
||||
context: GetServerSidePropsContext
|
||||
): Promise<{ props: { config: Config } }> {
|
||||
const configByUrl = context.query.slug;
|
||||
const configPath = path.join(process.cwd(), 'data/configs', `${configByUrl}.json`);
|
||||
const configExists = fs.existsSync(configPath);
|
||||
if (!configExists) {
|
||||
// Redirect to 404
|
||||
context.res.writeHead(301, { Location: '/404' });
|
||||
context.res.end();
|
||||
return {
|
||||
props: {
|
||||
config: {
|
||||
name: 'Default config',
|
||||
services: [],
|
||||
settings: {
|
||||
searchUrl: 'https://www.google.com/search?q=',
|
||||
},
|
||||
modules: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
// Print loaded config
|
||||
return {
|
||||
props: {
|
||||
config: JSON.parse(config),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function HomePage(props: any) {
|
||||
const { config: initialConfig }: { config: Config } = props;
|
||||
const { setConfig } = useConfig();
|
||||
useEffect(() => {
|
||||
setConfig(initialConfig);
|
||||
}, [initialConfig]);
|
||||
return (
|
||||
<>
|
||||
<AppShelf />
|
||||
<LoadConfigComponent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Homarr - A homepage for your server!</title>
|
||||
<title>Homarr 🦞</title>
|
||||
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
|
||||
<link rel="shortcut icon" href="/favicon.svg" />
|
||||
</Head>
|
||||
|
||||
@@ -6,7 +6,6 @@ const stylesServer = createStylesServer();
|
||||
export default class _Document extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
|
||||
// Add your app specific logic here
|
||||
|
||||
return {
|
||||
|
||||
29
src/pages/api/modules/ping.ts
Normal file
29
src/pages/api/modules/ping.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import axios from 'axios';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
async function Get(req: NextApiRequest, res: NextApiResponse) {
|
||||
// Parse req.body as a ServiceItem
|
||||
const { url } = req.query;
|
||||
await axios
|
||||
.get(url as string)
|
||||
.then((response) => {
|
||||
res.status(200).json(response.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
res.status(500).json(error);
|
||||
});
|
||||
// // Make a request to the URL
|
||||
// const response = await axios.get(url);
|
||||
// // Return the response
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Filter out if the reuqest is a POST or a GET
|
||||
if (req.method === 'GET') {
|
||||
return Get(req, res);
|
||||
}
|
||||
return res.status(405).json({
|
||||
statusCode: 405,
|
||||
message: 'Method not allowed',
|
||||
});
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import AppShelf from '../components/AppShelf/AppShelf';
|
||||
import LoadConfigComponent from '../components/Config/LoadConfig';
|
||||
import { Config } from '../tools/types';
|
||||
import { useConfig } from '../tools/state';
|
||||
import { migrateToIdConfig } from '../tools/migrate';
|
||||
|
||||
export async function getServerSideProps({
|
||||
req,
|
||||
@@ -26,10 +27,9 @@ export async function getServerSideProps({
|
||||
name: cookie.toString(),
|
||||
services: [],
|
||||
settings: {
|
||||
enabledModules: [],
|
||||
searchBar: true,
|
||||
searchUrl: 'https://www.google.com/search?q=',
|
||||
},
|
||||
modules: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -48,7 +48,8 @@ export default function HomePage(props: any) {
|
||||
const { config: initialConfig }: { config: Config } = props;
|
||||
const { config, loadConfig, setConfig, getConfigs } = useConfig();
|
||||
useEffect(() => {
|
||||
setConfig(initialConfig);
|
||||
const migratedConfig = migrateToIdConfig(initialConfig);
|
||||
setConfig(migratedConfig);
|
||||
}, [initialConfig]);
|
||||
return (
|
||||
<>
|
||||
|
||||
14
src/tools/migrate.ts
Normal file
14
src/tools/migrate.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Config } from './types';
|
||||
|
||||
export function migrateToIdConfig(config: Config): Config {
|
||||
// Set the config and add an ID to all the services that don't have one
|
||||
const services = config.services.map((service) => ({
|
||||
...service,
|
||||
id: service.id ?? uuidv4(),
|
||||
}));
|
||||
return {
|
||||
...config,
|
||||
services,
|
||||
};
|
||||
}
|
||||
@@ -17,10 +17,9 @@ const configContext = createContext<configContextType>({
|
||||
name: 'default',
|
||||
services: [],
|
||||
settings: {
|
||||
searchBar: true,
|
||||
searchUrl: 'https://www.google.com/search?q=',
|
||||
enabledModules: [],
|
||||
searchUrl: 'https://google.com/search?q=',
|
||||
},
|
||||
modules: {},
|
||||
},
|
||||
setConfig: () => {},
|
||||
loadConfig: async (name: string) => {},
|
||||
@@ -44,10 +43,9 @@ export function ConfigProvider({ children }: Props) {
|
||||
name: 'default',
|
||||
services: [],
|
||||
settings: {
|
||||
searchBar: true,
|
||||
searchUrl: 'https://www.google.com/search?q=',
|
||||
enabledModules: [],
|
||||
},
|
||||
modules: {},
|
||||
});
|
||||
|
||||
async function loadConfig(configName: string) {
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
import { OptionValues } from '../components/modules/modules';
|
||||
|
||||
export interface Settings {
|
||||
searchUrl: string;
|
||||
searchBar: boolean;
|
||||
enabledModules: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
name: string;
|
||||
services: serviceItem[];
|
||||
settings: Settings;
|
||||
modules: {
|
||||
[key: string]: ConfigModule;
|
||||
};
|
||||
}
|
||||
|
||||
interface ConfigModule {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
options: {
|
||||
[key: string]: OptionValues;
|
||||
};
|
||||
}
|
||||
|
||||
export const ServiceTypeList = [
|
||||
'Other',
|
||||
'Sonarr',
|
||||
'Radarr',
|
||||
'Lidarr',
|
||||
'qBittorrent',
|
||||
'Plex',
|
||||
'Emby',
|
||||
'Lidarr',
|
||||
'Plex',
|
||||
'Radarr',
|
||||
'Sonarr',
|
||||
'qBittorrent',
|
||||
];
|
||||
export type ServiceType =
|
||||
| 'Other'
|
||||
| 'Sonarr'
|
||||
| 'Radarr'
|
||||
| 'Emby'
|
||||
| 'Lidarr'
|
||||
| 'qBittorrent'
|
||||
| 'Plex'
|
||||
| 'Emby';
|
||||
| 'Radarr'
|
||||
| 'Sonarr'
|
||||
| 'qBittorrent';
|
||||
|
||||
export interface serviceItem {
|
||||
[x: string]: any;
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user