Merge pull request #144 from ajnart/dev

v0.5.0 : Quality of life and dev experience
This commit is contained in:
Bjorn Lammers
2022-05-23 21:49:14 +02:00
committed by ajnart
36 changed files with 2183 additions and 3111 deletions

View File

@@ -1,14 +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,

View File

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

View File

@@ -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)
@@ -53,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)**
@@ -106,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

View File

@@ -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"
]
}
}

View File

@@ -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": []
}
}

View File

@@ -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="
}
}

View File

@@ -1,2 +1,2 @@
export const REPO_URL = 'ajnart/homarr';
export const CURRENT_VERSION = 'v0.4.0';
export const CURRENT_VERSION = 'v0.5.0';

View File

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

View File

@@ -1,91 +1,80 @@
{
"name": "homarr",
"version": "0.4.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-mock": "^2.3.2",
"storybook-addon-turbo-build": "^1.1.0",
"storybook-dark-mode": "^1.0.9",
"ts-jest": "^27.1.4",
"typescript": "4.6.3"
},
"resolutions": {
"@types/react": "17.0.30"
}
"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"
}
}

View File

@@ -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;
},
@@ -166,11 +112,12 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
<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,
};
@@ -213,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

View File

@@ -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',

View File

@@ -1,106 +1,79 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Text, AspectRatio, Card, Image, Center, Grid, createStyles } 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 PingComponent from '../modules/ping/PingModule';
const useStyles = createStyles((theme) => ({
item: {
transition: 'box-shadow 150ms ease, transform 100ms ease',
'&:hover': {
boxShadow: `${theme.shadows.md} !important`,
transform: 'scale(1.05)',
},
},
}));
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 key={service.name} 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 { 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>
<Text mt="sm" align="center" lineClamp={1} weight={550}>
{service.name}
</Text>
<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>
);
}
export default AppShelf;

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

View File

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

View File

@@ -22,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}
@@ -54,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,

View File

@@ -0,0 +1,2 @@
export { default as AppShelf } from './AppShelf';
export * from './AppShelfItem';

View File

@@ -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']}

View File

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

View File

@@ -1,4 +1,5 @@
import { Group, Image, Text } from '@mantine/core';
import { NextLink } from '@mantine/next';
import * as React from 'react';
export function Logo({ style }: any) {
@@ -11,14 +12,22 @@ export function Logo({ style }: any) {
position: 'relative',
}}
/>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{ from: 'red', to: 'orange', deg: 145 }}
<NextLink
style={{
textDecoration: 'none',
position: 'relative',
}}
href="/"
>
Homarr
</Text>
<Text
sx={style}
weight="bold"
variant="gradient"
gradient={{ from: 'red', to: 'orange', deg: 145 }}
>
Homarr
</Text>
</NextLink>
</Group>
);
}

View File

@@ -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]);

View File

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

View File

@@ -21,13 +21,8 @@ export const DateModule: IModule = {
export default function DateComponent(props: any) {
const [date, setDate] = useState(new Date());
const { config } = useConfig();
const hours = date.getHours();
const minutes = date.getMinutes();
const isFullTime =
config.settings[`${DateModule.title}.full`] === undefined
? true
: config.settings[`${DateModule.title}.full`];
const formatString = isFullTime ? 'HH:mm' : 'h:mm a';
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(() => {

View File

@@ -5,9 +5,9 @@ import { IModule } from './modules';
export function ModuleWrapper(props: any) {
const { module }: { module: IModule } = props;
const { config, setConfig } = useConfig();
const enabledModules = config.settings.enabledModules ?? [];
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) {
@@ -18,25 +18,31 @@ export function ModuleWrapper(props: any) {
// 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
config.settings[optionName] ??
(module.options && module.options[keys[index]].value) ??
false
moduleInConfig?.options?.[keys[index]]?.value ?? false
}
defaultValue={config.settings[optionName] ?? false}
key={keys[index]}
onClick={(e) => {
setConfig({
...config,
settings: {
...config.settings,
enabledModules: [...config.settings.enabledModules],
[optionName]: e.currentTarget.checked,
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,
},
},
},
},
});
}}
@@ -46,7 +52,6 @@ export function ModuleWrapper(props: any) {
}
});
}
// Sussy baka
if (!isShown) {
return null;
}

View File

@@ -14,7 +14,7 @@ interface Option {
[x: string]: OptionValues;
}
interface OptionValues {
export interface OptionValues {
name: string;
value: boolean;
}

View File

@@ -6,6 +6,7 @@ export default {
};
const service: serviceItem = {
id: '1',
type: 'Other',
name: 'YouTube',
icon: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/youtube.png',

View File

@@ -19,8 +19,9 @@ export default function PingComponent(props: any) {
const { url }: { url: string } = props;
const [isOnline, setOnline] = useState<State>('loading');
const exists = config.modules?.[PingModule.title]?.enabled ?? false;
useEffect(() => {
if (!config.settings.enabledModules.includes('Ping Services')) {
if (!exists) {
return;
}
axios
@@ -32,7 +33,7 @@ export default function PingComponent(props: any) {
setOnline('down');
});
}, []);
if (!config.settings.enabledModules.includes('Ping Services')) {
if (!exists) {
return null;
}
return (

View File

@@ -26,7 +26,7 @@ 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 queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
const textInput = useRef<HTMLInputElement>();
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
@@ -46,7 +46,10 @@ export default function SearchBar(props: any) {
});
// If enabled modules doesn't contain the module, return null
if (!config.settings.enabledModules.includes(SearchModule.title)) {
// If module in enabled
const exists = config.modules?.[SearchModule.title]?.enabled ?? false;
if (!exists) {
return null;
}

View File

@@ -132,9 +132,7 @@ export default function WeatherComponent(props: any) {
const { config } = useConfig();
const [weather, setWeather] = useState({} as WeatherResponse);
const isFahrenheit: boolean =
config.settings[`${WeatherModule.title}.freedomunit`] === undefined
? false
: config.settings[`${WeatherModule.title}.freedomunit`];
config?.modules?.[WeatherModule.title]?.options?.freedomunit?.value ?? false;
if ('geolocation' in navigator && location.lat === 0 && location.lng === 0) {
navigator.geolocation.getCurrentPosition((position) => {
@@ -163,10 +161,10 @@ export default function WeatherComponent(props: any) {
<Group spacing={0}>
<WeatherIcon code={weather.current_weather.weathercode} />
<Space mx="sm" />
<span>{weather.daily.temperature_2m_max[0]}°C</span>
<span>{usePerferedUnit(weather.daily.temperature_2m_max[0])}</span>
<ArrowUpRight size={16} style={{ right: 15 }} />
<Space mx="sm" />
<span>{weather.daily.temperature_2m_min[0]}°C</span>
<span>{usePerferedUnit(weather.daily.temperature_2m_min[0])}</span>
<ArrowDownRight size={16} />
</Group>
</Group>

View File

@@ -78,10 +78,9 @@ export default function NothingFoundBackground() {
<div className={classes.inner}>
<Illustration className={classes.image} />
<div className={classes.content}>
<Title className={classes.title}>Nothing to see here</Title>
<Title className={classes.title}>Config not found</Title>
<Text color="dimmed" size="lg" align="center" className={classes.description}>
Page you are trying to open does not exist. You may have mistyped the address, or the
page has been moved to another URL. If you think this is an error contact support.
The config you are trying to access does not exist. Please check the URL and try again.
</Text>
<Group position="center">
<NextLink href="/">

View File

@@ -1,8 +1,54 @@
import { Title } from '@mantine/core';
import { useRouter } from 'next/router';
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 default function SlugPage(props: any) {
const router = useRouter();
const { slug } = router.query;
return <Title>ok</Title>;
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 />
</>
);
}

View File

@@ -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 {

View File

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

View File

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

View File

@@ -1,37 +1,49 @@
import { OptionValues } from '../components/modules/modules';
export interface Settings {
searchUrl: string;
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;
}

4342
yarn.lock

File diff suppressed because it is too large Load Diff