Add new config format

This commit is contained in:
Meierschlumpf
2022-12-04 17:36:30 +01:00
parent b2f5149527
commit d5a3b3f3ba
76 changed files with 2461 additions and 1034 deletions

View File

@@ -1,20 +1,33 @@
import { Center, Loader, Select, Tooltip } from '@mantine/core';
import { setCookie } from 'cookies-next';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useConfig } from '../../tools/state';
import { useState } from 'react';
import { useConfigContext } from '../../config/provider';
export default function ConfigChanger() {
const { config, loadConfig, setConfig, getConfigs } = useConfig();
const [configList, setConfigList] = useState<string[]>([]);
const [value, setValue] = useState(config.name);
const { t } = useTranslation('settings/general/config-changer');
const { name: configName } = useConfigContext();
//const loadConfig = useConfigStore((x) => x.loadConfig);
const { data: configs, isLoading, isError } = useConfigsQuery();
const [activeConfig, setActiveConfig] = useState(configName);
const onConfigChange = (value: string) => {
// TODO: check what should happen here with @manuel-rw
// Wheter it should check for the current url and then load the new config only on index
// Or it should always load the selected config and open index or ?
setActiveConfig(value);
/*
loadConfig(e ?? 'default');
setCookie('config-name', e ?? 'default', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
*/
};
useEffect(() => {
getConfigs().then((configs) => setConfigList(configs));
}, [config]);
// If configlist is empty, return a loading indicator
if (configList.length === 0) {
if (isLoading || !configs || configs?.length === 0 || !configName) {
return (
<Tooltip label={"Loading your configs. This doesn't load in vercel."}>
<Center>
@@ -23,23 +36,22 @@ export default function ConfigChanger() {
</Tooltip>
);
}
// return <Select data={[{ value: '1', label: '1' },]} onChange={(e) => console.log(e)} value="1" />;
return (
<Select
label={t('configSelect.label')}
value={value}
defaultValue={config.name}
onChange={(e) => {
loadConfig(e ?? 'default');
setCookie('config-name', e ?? 'default', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
}}
data={
// If config list is empty, return the current config
configList.length === 0 ? [config.name] : configList
}
value={activeConfig}
onChange={onConfigChange}
data={configs}
/>
);
}
const useConfigsQuery = () => {
return useQuery({
queryKey: ['config/get-all'],
queryFn: fetchConfigs,
});
};
const fetchConfigs = async () => (await (await fetch('/api/configs')).json()) as string[];

View File

@@ -1,99 +0,0 @@
import { Button, Group, Modal, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import axios from 'axios';
import fileDownload from 'js-file-download';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import {
IconCheck as Check,
IconDownload as Download,
IconPlus as Plus,
IconTrash as Trash,
IconX as X,
} from '@tabler/icons';
import { useConfig } from '../../tools/state';
export default function SaveConfigComponent(props: any) {
const [opened, setOpened] = useState(false);
const { config, setConfig } = useConfig();
const { t } = useTranslation('settings/general/config-changer');
const form = useForm({
initialValues: {
configName: config.name,
},
});
function onClick(e: any) {
if (config) {
fileDownload(JSON.stringify(config, null, '\t'), `${config.name}.json`);
}
}
return (
<Group spacing="xs">
<Modal radius="md" opened={opened} onClose={() => setOpened(false)} title={t('modal.title')}>
<form
onSubmit={form.onSubmit((values) => {
setConfig({ ...config, name: values.configName });
setOpened(false);
showNotification({
title: t('modal.events.configSaved.title'),
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: t('modal.events.configSaved.message', { configName: values.configName }),
});
})}
>
<TextInput
required
label={t('modal.form.configName.label')}
placeholder={t('modal.form.configName.placeholder')}
{...form.getInputProps('configName')}
/>
<Group position="right" mt="md">
<Button type="submit">{t('modal.form.submitButton')}</Button>
</Group>
</form>
</Modal>
<Button size="xs" leftIcon={<Download />} variant="outline" onClick={onClick}>
{t('buttons.download')}
</Button>
<Button
size="xs"
leftIcon={<Trash />}
variant="outline"
onClick={() => {
axios
.delete(`/api/configs/${config.name}`)
.then(() => {
showNotification({
title: t('buttons.delete.notifications.deleted.title'),
icon: <Check />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: t('buttons.delete.notifications.deleted.message'),
});
})
.catch(() => {
showNotification({
title: t('buttons.delete.notifications.deleteFailed.title'),
icon: <X />,
color: 'red',
autoClose: 1500,
radius: 'md',
message: t('buttons.delete.notifications.deleteFailed.message'),
});
});
setConfig({ ...config, name: 'default' });
}}
>
{t('buttons.delete.text')}
</Button>
<Button size="xs" leftIcon={<Plus />} variant="outline" onClick={() => setOpened(true)}>
{t('buttons.saveCopy')}
</Button>
</Group>
);
}

View File

@@ -0,0 +1,16 @@
import { DashboardDetailView } from './Views/DetailView';
import { DashboardEditView } from './Views/EditView';
import { useEditModeStore } from './Views/store';
interface DashboardProps {}
export const Dashboard = () => {
const isEditMode = useEditModeStore((x) => x.enabled);
return (
<>
{/* The following elemens are splitted because gridstack doesn't reinitialize them when using same item. */}
{isEditMode ? <DashboardEditView /> : <DashboardDetailView />}
</>
);
};

View File

@@ -0,0 +1,91 @@
import { Card, Center, Text, UnstyledButton } from '@mantine/core';
import { NextLink } from '@mantine/next';
import { createStyles } from '@mantine/styles';
import { ServiceType } from '../../../../types/service';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useEditModeStore } from '../../Views/store';
import { BaseTileProps } from '../type';
interface ServiceTileProps extends BaseTileProps {
service: ServiceType;
}
export const ServiceTile = ({ className, service }: ServiceTileProps) => {
const isEditMode = useEditModeStore((x) => x.enabled);
const { cx, classes } = useStyles();
const {
classes: { card: cardClass },
} = useCardStyles();
const inner = (
<>
<Text align="center" weight={500} size="md" className={classes.serviceName}>
{service.name}
</Text>
<Center style={{ height: '75%', flex: 1 }}>
<img className={classes.image} src={service.appearance.iconUrl} alt="" />
</Center>
</>
);
return (
<Card className={cx(className, cardClass)} withBorder radius="lg" shadow="md">
{isEditMode &&
{
/*<AppShelfMenu service={service} />*/
}}{' '}
{/* TODO: change to serviceMenu */}
{!service.url || isEditMode ? (
<UnstyledButton
className={classes.button}
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
>
{inner}
</UnstyledButton>
) : (
<UnstyledButton
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
component={NextLink}
href={service.url}
target={service.behaviour.isOpeningNewTab ? '_blank' : '_self'}
className={cx(classes.button, classes.link)}
>
{inner}
</UnstyledButton>
)}
{/*<ServicePing service={service} />*/}
</Card>
);
};
const useStyles = createStyles((theme, _params, getRef) => {
return {
image: {
ref: getRef('image'),
maxHeight: '80%',
maxWidth: '80%',
transition: 'transform 100ms ease-in-out',
},
serviceName: {
ref: getRef('serviceName'),
},
button: {
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
},
link: {
[`&:hover .${getRef('image')}`]: {
// TODO: add styles for image when hovering card
},
[`&:hover .${getRef('serviceName')}`]: {
// TODO: add styles for service name when hovering card
},
},
};
});

View File

@@ -0,0 +1,48 @@
import { ReactNode, RefObject } from 'react';
interface GridstackTileWrapperProps {
id: string;
type: 'service' | 'module';
x?: number;
y?: number;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
itemRef: RefObject<HTMLDivElement>;
children: ReactNode;
}
export const GridstackTileWrapper = ({
id,
type,
x,
y,
width,
height,
minWidth,
minHeight,
maxWidth,
maxHeight,
children,
itemRef,
}: GridstackTileWrapperProps) => (
<div
className="grid-stack-item"
data-type={type}
data-id={id}
gs-x={x}
gs-y={y}
gs-w={width}
gs-h={height}
gs-min-w={minWidth}
gs-min-h={minHeight}
gs-max-w={maxWidth}
gs-max-h={maxHeight}
ref={itemRef}
>
{children}
</div>
);

View File

@@ -0,0 +1,76 @@
import { IntegrationsType } from '../../../types/integration';
import { ServiceTile } from './Service/Service';
/*import { CalendarTile } from './calendar';
import { ClockTile } from './clock';
import { DashDotTile } from './dash-dot';
import { WeatherTile } from './weather';*/
type TileDefinitionProps = {
[key in keyof IntegrationsType | 'service']: {
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
component: React.ElementType;
};
};
// TODO: change components for other modules
export const Tiles: TileDefinitionProps = {
service: {
component: ServiceTile,
minWidth: 2,
maxWidth: 12,
minHeight: 2,
maxHeight: 12,
},
bitTorrent: {
component: CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,
maxHeight: 12,
},
calendar: {
component: CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,
maxHeight: 12,
},
clock: {
component: ClockTile,
minWidth: 4,
maxWidth: 12,
minHeight: 2,
maxHeight: 12,
},
dashDot: {
component: DashDotTile,
minWidth: 4,
maxWidth: 9,
minHeight: 5,
maxHeight: 14,
},
torrentNetworkTraffic: {
component: CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,
maxHeight: 12,
},
useNet: {
component: CalendarTile,
minWidth: 4,
maxWidth: 12,
minHeight: 5,
maxHeight: 12,
},
weather: {
component: WeatherTile,
minWidth: 4,
maxWidth: 12,
minHeight: 2,
maxHeight: 12,
},
};

View File

@@ -0,0 +1,3 @@
export interface BaseTileProps {
className?: string;
}

View File

@@ -0,0 +1,5 @@
import { DashboardView } from './main';
export const DashboardDetailView = () => {
return <DashboardView />;
};

View File

@@ -0,0 +1,5 @@
import { DashboardView } from './main';
export const DashboardEditView = () => {
return <DashboardView />;
};

View File

@@ -0,0 +1,39 @@
import { Group, Stack } from '@mantine/core';
import { useMemo } from 'react';
import { useConfigContext } from '../../../config/provider';
import { ServiceTile } from '../Tiles/Service/Service';
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
export const DashboardView = () => {
const wrappers = useWrapperItems();
return (
<Group align="top" h="100%">
{/*<DashboardSidebar location="left" />*/}
<Stack mx={-10} style={{ flexGrow: 1 }}>
{wrappers.map(
(item) =>
item.type === 'category'
? 'category' //<DashboardCategory key={item.id} category={item as unknown as CategoryType} />
: 'wrapper' //<DashboardWrapper key={item.id} wrapper={item as WrapperType} />
)}
</Stack>
{/*<DashboardSidebar location="right" />*/}
</Group>
);
};
const useWrapperItems = () => {
const { config } = useConfigContext();
return useMemo(
() =>
config
? [
...config.categories.map((c) => ({ ...c, type: 'category' })),
...config.wrappers.map((w) => ({ ...w, type: 'wrapper' })),
].sort((a, b) => a.position - b.position)
: [],
[config?.categories, config?.wrappers]
);
};

View File

@@ -0,0 +1,11 @@
import create from 'zustand';
interface EditModeState {
enabled: boolean;
toggleEditMode: () => void;
}
export const useEditModeStore = create<EditModeState>((set) => ({
enabled: false,
toggleEditMode: () => set((state) => ({ enabled: !state.enabled })),
}));

View File

@@ -0,0 +1,72 @@
import { Card } from '@mantine/core';
import { RefObject } from 'react';
import { Tiles } from '../../Tiles/definition';
import { GridstackTileWrapper } from '../../Tiles/TileWrapper';
import { useGridstack } from '../gridstack/use-gridstack';
interface DashboardSidebarProps {
location: 'right' | 'left';
}
export const DashboardSidebar = ({ location }: DashboardSidebarProps) => {
const { refs, items, integrations } = useGridstack('sidebar', location);
const minRow = useMinRowForFullHeight(refs.wrapper);
return (
<Card
withBorder
w={300}
style={{
background: 'none',
borderStyle: 'dashed',
}}
>
<div
className="grid-stack grid-stack-sidebar"
style={{ transitionDuration: '0s', height: '100%' }}
data-sidebar={location}
gs-min-row={minRow}
ref={refs.wrapper}
>
{items.map((service) => {
const { component: TileComponent, ...tile } = Tiles['service'];
return (
<GridstackTileWrapper
id={service.id}
type="service"
key={service.id}
itemRef={refs.items.current[service.id]}
{...tile}
{...service.shape.location}
{...service.shape.size}
>
<TileComponent className="grid-stack-item-content" service={service} />
</GridstackTileWrapper>
);
})}
{Object.entries(integrations).map(([k, v]) => {
const { component: TileComponent, ...tile } = Tiles[k as keyof typeof Tiles];
return (
<GridstackTileWrapper
id={k}
type="module"
key={k}
itemRef={refs.items.current[k]}
{...tile}
{...v.shape.location}
{...v.shape.size}
>
<TileComponent className="grid-stack-item-content" module={v} />
</GridstackTileWrapper>
);
})}
</div>
</Card>
);
};
const useMinRowForFullHeight = (wrapperRef: RefObject<HTMLDivElement>) => {
return wrapperRef.current ? Math.floor(wrapperRef.current!.offsetHeight / 64) : 2;
};

View File

@@ -0,0 +1,68 @@
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
import { MutableRefObject, RefObject } from 'react';
import { IntegrationsType } from '../../../../types/integration';
import { ServiceType } from '../../../../types/service';
export const initializeGridstack = (
areaType: 'wrapper' | 'category' | 'sidebar',
wrapperRef: RefObject<HTMLDivElement>,
gridRef: MutableRefObject<GridStack | undefined>,
itemRefs: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>,
areaId: string,
items: ServiceType[],
integrations: IntegrationsType,
isEditMode: boolean,
events: {
onChange: (changedNode: GridStackNode) => void;
onAdd: (addedNode: GridStackNode) => void;
}
) => {
if (!wrapperRef.current) return;
// calculates the currently available count of columns
const columnCount = areaType === 'sidebar' ? 4 : Math.floor(wrapperRef.current.offsetWidth / 64);
const minRow = areaType !== 'sidebar' ? 1 : Math.floor(wrapperRef.current.offsetHeight / 64);
// initialize gridstack
gridRef.current = GridStack.init(
{
column: columnCount,
margin: 10,
cellHeight: 64,
float: true,
alwaysShowResizeHandle: 'mobile',
acceptWidgets: true,
disableOneColumnMode: true,
staticGrid: !isEditMode,
minRow,
},
// selector of the gridstack item (it's eather category or wrapper)
`.grid-stack-${areaType}[data-${areaType}='${areaId}']`
);
const grid = gridRef.current;
// Add listener for moving items around in a wrapper
grid.on('change', (_, el) => {
const nodes = el as GridStackNode[];
const firstNode = nodes.at(0);
if (!firstNode) return;
events.onChange(firstNode);
});
// Add listener for moving items in config from one wrapper to another
grid.on('added', (_, el) => {
const nodes = el as GridStackNode[];
const firstNode = nodes.at(0);
if (!firstNode) return;
events.onAdd(firstNode);
});
grid.batchUpdate();
grid.removeAll(false);
items.forEach(
({ id }) =>
itemRefs.current[id] && grid.makeWidget(itemRefs.current[id].current as HTMLDivElement)
);
Object.keys(integrations).forEach(
(key) =>
itemRefs.current[key] && grid.makeWidget(itemRefs.current[key].current as HTMLDivElement)
);
grid.batchUpdate(false);
};

View File

@@ -0,0 +1,231 @@
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
import {
createRef,
LegacyRef,
MutableRefObject,
RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useResize } from '../../../../hooks/use-resize';
import { IntegrationsType } from '../../../../types/integration';
import { ServiceType } from '../../../../types/service';
import { TileBaseType } from '../../../../types/tile';
import { useEditModeStore } from '../../Views/store';
import { initializeGridstack } from './init-gridstack';
interface UseGristackReturnType {
items: ServiceType[];
integrations: Partial<IntegrationsType>;
refs: {
wrapper: RefObject<HTMLDivElement>;
items: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>;
gridstack: MutableRefObject<GridStack | undefined>;
};
}
export const useGridstack = (
areaType: 'wrapper' | 'category' | 'sidebar',
areaId: string
): UseGristackReturnType => {
const isEditMode = useEditModeStore((x) => x.enabled);
const { config, name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
// define reference for wrapper - is used to calculate the width of the wrapper
const wrapperRef = useRef<HTMLDivElement>(null);
// references to the diffrent items contained in the gridstack
const itemRefs = useRef<Record<string, RefObject<HTMLDivElement>>>({});
// reference of the gridstack object for modifications after initialization
const gridRef = useRef<GridStack>();
// width of the wrapper (updating on page resize)
const { width, height } = useResize(wrapperRef);
const items = useMemo(
() =>
config?.services.filter(
(x) =>
x.area.type === areaType &&
(x.area.type === 'sidebar'
? x.area.properties.location === areaId
: x.area.properties.id === areaId)
) ?? [],
[config]
);
const integrations = useMemo(() => {
if (!config) return;
return (Object.entries(config.integrations) as [keyof IntegrationsType, TileBaseType][])
.filter(
([k, v]) =>
v.area.type === areaType &&
(v.area.type === 'sidebar'
? v.area.properties.location === areaId
: v.area.properties.id === areaId)
)
.reduce((prev, [k, v]) => {
prev[k] = v as unknown as any;
return prev;
}, {} as IntegrationsType);
}, [config]);
// define items in itemRefs for easy access and reference to items
if (
Object.keys(itemRefs.current).length !==
items.length + Object.keys(integrations ?? {}).length
) {
items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => {
itemRefs.current[id] = itemRefs.current[id] || createRef();
});
Object.keys(integrations ?? {}).forEach((k) => {
itemRefs.current[k] = itemRefs.current[k] || createRef();
});
}
// change column count depending on the width and the gridRef
useEffect(() => {
if (areaType === 'sidebar') return;
gridRef.current?.column(Math.floor(width / 64), 'moveScale');
}, [gridRef, width]);
const onChange = isEditMode
? (changedNode: GridStackNode) => {
if (!configName) return;
const itemType = changedNode.el?.getAttribute('data-type');
const itemId = changedNode.el?.getAttribute('data-id');
if (!itemType || !itemId) return;
// Updates the config and defines the new position of the item
updateConfig(configName, (previous) => {
const currentItem =
itemType === 'service'
? previous.services.find((x) => x.id === itemId)
: previous.integrations[itemId as keyof typeof previous.integrations];
if (!currentItem) return previous;
currentItem.shape = {
location: {
x: changedNode.x ?? currentItem.shape.location.x,
y: changedNode.y ?? currentItem.shape.location.y,
},
size: {
width: changedNode.w ?? currentItem.shape.size.width,
height: changedNode.h ?? currentItem.shape.size.height,
},
};
if (itemType === 'service') {
return {
...previous,
services: [
...previous.services.filter((x) => x.id !== itemId),
{ ...(currentItem as ServiceType) },
],
};
}
const integrationsCopy = { ...previous.integrations };
integrationsCopy[itemId as keyof typeof integrationsCopy] = currentItem as any;
return {
...previous,
integrations: integrationsCopy,
};
});
}
: () => {};
const onAdd = isEditMode
? (addedNode: GridStackNode) => {
if (!configName) return;
const itemType = addedNode.el?.getAttribute('data-type');
const itemId = addedNode.el?.getAttribute('data-id');
if (!itemType || !itemId) return;
// Updates the config and defines the new position and wrapper of the item
updateConfig(configName, (previous) => {
const currentItem =
itemType === 'service'
? previous.services.find((x) => x.id === itemId)
: previous.integrations[itemId as keyof typeof previous.integrations];
if (!currentItem) return previous;
if (areaType === 'sidebar') {
currentItem.area = {
type: areaType,
properties: {
location: areaId as 'right' | 'left',
},
};
} else {
currentItem.area = {
type: areaType,
properties: {
id: areaId,
},
};
}
currentItem.shape = {
location: {
x: addedNode.x ?? currentItem.shape.location.x,
y: addedNode.y ?? currentItem.shape.location.y,
},
size: {
width: addedNode.w ?? currentItem.shape.size.width,
height: addedNode.h ?? currentItem.shape.size.height,
},
};
if (itemType === 'service') {
return {
...previous,
services: [
...previous.services.filter((x) => x.id !== itemId),
{ ...(currentItem as ServiceType) },
],
};
}
const integrationsCopy = { ...previous.integrations };
integrationsCopy[itemId as keyof typeof integrationsCopy] = currentItem as any;
return {
...previous,
integrations: integrationsCopy,
};
});
}
: () => {};
// initialize the gridstack
useLayoutEffect(() => {
initializeGridstack(
areaType,
wrapperRef,
gridRef,
itemRefs,
areaId,
items,
integrations ?? {},
isEditMode,
{
onChange,
onAdd,
}
);
}, [items.length, wrapperRef.current, Object.keys(integrations ?? {}).length]);
return {
items,
integrations: integrations ?? {},
refs: {
items: itemRefs,
wrapper: wrapperRef,
gridstack: gridRef,
},
};
};

View File

@@ -1,57 +0,0 @@
import React, { useState } from 'react';
import { createStyles, Switch, Group } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({
root: {
position: 'relative',
'& *': {
cursor: 'pointer',
},
},
icon: {
pointerEvents: 'none',
position: 'absolute',
zIndex: 1,
top: 3,
},
iconLight: {
left: 4,
color: theme.white,
},
iconDark: {
right: 4,
color: theme.colors.gray[6],
},
}));
export function SearchNewTabSwitch() {
const { config, setConfig } = useConfig();
const { classes, cx } = useStyles();
const defaultPosition = config?.settings?.searchNewTab ?? true;
const [openInNewTab, setOpenInNewTab] = useState<boolean>(defaultPosition);
const { t } = useTranslation('settings/general/search-engine');
const toggleOpenInNewTab = () => {
setOpenInNewTab(!openInNewTab);
setConfig({
...config,
settings: {
...config.settings,
searchNewTab: !openInNewTab,
},
});
};
return (
<Group>
<div className={classes.root}>
<Switch checked={openInNewTab} onChange={() => toggleOpenInNewTab()} size="md" />
</div>
{t('searchNewTab.label')}
</Group>
);
}

View File

@@ -1,86 +0,0 @@
import { TextInput, Button, Stack, Textarea } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { ColorSelector } from './ColorSelector';
import { OpacitySelector } from './OpacitySelector';
import { AppCardWidthSelector } from './AppCardWidthSelector';
import { ShadeSelector } from './ShadeSelector';
import { GrowthSelector } from './GrowthSelector';
export default function TitleChanger() {
const { config, setConfig } = useConfig();
const { t } = useTranslation('settings/customization/page-appearance');
const form = useForm({
initialValues: {
title: config.settings.title,
logo: config.settings.logo,
favicon: config.settings.favicon,
background: config.settings.background,
customCSS: config.settings.customCSS,
},
});
const saveChanges = (values: {
title?: string;
logo?: string;
favicon?: string;
background?: string;
customCSS?: string;
}) => {
setConfig({
...config,
settings: {
...config.settings,
title: values.title,
logo: values.logo,
favicon: values.favicon,
background: values.background,
customCSS: values.customCSS,
},
});
};
return (
<Stack mb="md" mr="sm" mt="xs">
<form onSubmit={form.onSubmit((values) => saveChanges(values))}>
<Stack>
<TextInput
label={t('pageTitle.label')}
placeholder="Homarr 🦞"
{...form.getInputProps('title')}
/>
<TextInput
label={t('logo.label')}
placeholder="/imgs/logo.png"
{...form.getInputProps('logo')}
/>
<TextInput
label={t('favicon.label')}
placeholder="/imgs/favicon/favicon.png"
{...form.getInputProps('favicon')}
/>
<TextInput
label={t('background.label')}
placeholder="/img/background.png"
{...form.getInputProps('background')}
/>
<Textarea
minRows={5}
label={t('customCSS.label')}
placeholder={t('customCSS.placeholder')}
{...form.getInputProps('customCSS')}
/>
<Button type="submit">{t('buttons.submit')}</Button>
</Stack>
</form>
<GrowthSelector />
<ColorSelector type="primary" />
<ColorSelector type="secondary" />
<ShadeSelector />
<OpacitySelector />
<AppCardWidthSelector />
</Stack>
);
}

View File

@@ -1,34 +0,0 @@
import React from 'react';
import { Text, Slider, Stack } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
export function AppCardWidthSelector() {
const { config, setConfig } = useConfig();
const { t } = useTranslation('settings/customization/app-width');
const setappCardWidth = (appCardWidth: number) => {
setConfig({
...config,
settings: {
...config.settings,
appCardWidth,
},
});
};
return (
<Stack spacing="xs">
<Text>{t('label')}</Text>
<Slider
label={config.settings.appCardWidth?.toFixed(1)}
defaultValue={config.settings.appCardWidth ?? 0.7}
step={0.1}
min={0.3}
max={1.2}
styles={{ markLabel: { fontSize: 'xx-small' } }}
onChange={(value) => setappCardWidth(value)}
/>
</Stack>
);
}

View File

@@ -1,93 +0,0 @@
import React, { useState } from 'react';
import { ColorSwatch, Grid, Group, Popover, Text, useMantineTheme } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { useColorTheme } from '../../tools/color';
interface ColorControlProps {
type: string;
}
export function ColorSelector({ type }: ColorControlProps) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const { primaryColor, secondaryColor, setPrimaryColor, setSecondaryColor } = useColorTheme();
const { t } = useTranslation('settings/customization/color-selector');
const theme = useMantineTheme();
const colors = Object.keys(theme.colors).map((color) => ({
swatch: theme.colors[color][6],
color,
}));
const configColor = type === 'primary' ? primaryColor : secondaryColor;
const setConfigColor = (color: string) => {
if (type === 'primary') {
setPrimaryColor(color);
setConfig({
...config,
settings: {
...config.settings,
primaryColor: color,
},
});
} else {
setSecondaryColor(color);
setConfig({
...config,
settings: {
...config.settings,
secondaryColor: color,
},
});
}
};
const swatches = colors.map(({ color, swatch }) => (
<Grid.Col span={2} key={color}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigColor(color)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
return (
<Group>
<Popover
width={250}
withinPortal
opened={opened}
onClose={() => setOpened(false)}
position="left"
withArrow
>
<Popover.Target>
<ColorSwatch
component="button"
type="button"
color={theme.colors[configColor][6]}
onClick={() => setOpened((o) => !o)}
size={22}
style={{ cursor: 'pointer' }}
/>
</Popover.Target>
<Popover.Dropdown>
<Grid gutter="lg" columns={14}>
{swatches}
</Grid>
</Popover.Dropdown>
</Popover>
<Text>
{t('suffix', {
color: type[0].toUpperCase() + type.slice(1),
})}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,102 @@
import { Button, Center, Group } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconCheck, IconDownload, IconPlus, IconTrash, IconX } from '@tabler/icons';
import { useMutation } from '@tanstack/react-query';
import fileDownload from 'js-file-download';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider';
import Tip from '../../layout/Tip';
import { CreateConfigCopyModal } from './ConfigActions/CreateCopyModal';
export default function ConfigActions() {
const { t } = useTranslation(['settings/general/config-changer', 'settings/common']);
const [createCopyModalOpened, createCopyModal] = useDisclosure(false);
const { config } = useConfigContext();
const { mutateAsync } = useDeleteConfigMutation(config?.configProperties.name ?? 'default');
if (!config) return null;
const handleDownload = () => {
// TODO: remove secrets
fileDownload(JSON.stringify(config, null, '\t'), `${config?.configProperties.name}.json`);
};
const handleDeletion = async () => {
await mutateAsync();
};
return (
<>
<CreateConfigCopyModal
opened={createCopyModalOpened}
closeModal={createCopyModal.close}
initialConfigName={config.configProperties.name}
/>
<Group spacing="xs" position="center">
<Button
size="xs"
leftIcon={<IconDownload size={18} />}
variant="default"
onClick={handleDownload}
>
{t('buttons.download')}
</Button>
<Button
size="xs"
leftIcon={<IconTrash size={18} />}
variant="default"
onClick={handleDeletion}
>
{t('buttons.delete.text')}
</Button>
<Button
size="xs"
leftIcon={<IconPlus size={18} />}
variant="default"
onClick={createCopyModal.open}
>
{t('buttons.saveCopy')}
</Button>
</Group>
<Center>
<Tip>{t('settings/common:tips.configTip')}</Tip>
</Center>
</>
);
}
const useDeleteConfigMutation = (configName: string) => {
const { t } = useTranslation(['settings/general/config-changer']);
return useMutation({
mutationKey: ['config/delete', { configName }],
mutationFn: () => fetchDeletion(configName),
onSuccess() {
showNotification({
title: t('buttons.delete.notifications.deleted.title'),
icon: <IconCheck />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: t('buttons.delete.notifications.deleted.message'),
});
// TODO: set config to default config and use fallback config if necessary
},
onError() {
showNotification({
title: t('buttons.delete.notifications.deleteFailed.title'),
icon: <IconX />,
color: 'red',
autoClose: 1500,
radius: 'md',
message: t('buttons.delete.notifications.deleteFailed.message'),
});
},
});
};
const fetchDeletion = async (configName: string) => {
return await (await fetch(`/api/configs/${configName}`)).json();
};

View File

@@ -0,0 +1,68 @@
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { showNotification } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../config/provider';
interface CreateConfigCopyModalProps {
opened: boolean;
closeModal: () => void;
initialConfigName: string;
}
export const CreateConfigCopyModal = ({
opened,
closeModal,
initialConfigName,
}: CreateConfigCopyModalProps) => {
const { t } = useTranslation(['settings/general/config-changer']);
const { config } = useConfigContext();
const form = useForm({
initialValues: {
configName: initialConfigName,
},
validate: {
configName: (v) => (!v ? t('modal.form.configName.validation.required') : null),
},
});
const handleClose = () => {
form.setFieldValue('configName', initialConfigName);
closeModal();
};
const handleSubmit = (values: typeof form.values) => {
if (!form.isValid) return;
// TODO: create config file with copied data
closeModal();
showNotification({
title: t('modal.events.configSaved.title'),
icon: <IconCheck />,
color: 'green',
autoClose: 1500,
radius: 'md',
message: t('modal.events.configSaved.message', { configName: values.configName }),
});
};
return (
<Modal
radius="md"
opened={opened}
onClose={handleClose}
title={<Title order={4}>{t('modal.title')}</Title>}
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label={t('modal.form.configName.label')}
placeholder={t('modal.form.configName.placeholder')}
{...form.getInputProps('configName')}
/>
<Group position="right" mt="md">
<Button type="submit">{t('modal.form.submitButton')}</Button>
</Group>
</form>
</Modal>
);
};

View File

@@ -5,9 +5,9 @@ import { forwardRef, useState } from 'react';
import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router';
import { getCookie, setCookie } from 'cookies-next';
import { getLanguageByCode, Language } from '../../tools/language';
import { getLanguageByCode, Language } from '../../../tools/language';
export default function LanguageSwitch() {
export default function LanguageSelect() {
const { t, i18n } = useTranslation('settings/general/internationalization');
const { changeLanguage } = i18n;
const configLocale = getCookie('config-locale');

View File

@@ -0,0 +1,44 @@
import { Switch } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { SearchEngineCommonSettingsType } from '../../../types/settings';
interface SearchEnabledSwitchProps {
defaultValue: boolean | undefined;
}
export function SearchEnabledSwitch({ defaultValue }: SearchEnabledSwitchProps) {
const { t } = useTranslation('settings/general/search-engine');
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const [enabled, setEnabled] = useState<boolean>(defaultValue ?? true);
if (!configName) return null;
const toggleEnabled = () => {
setEnabled(!enabled);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
common: {
...prev.settings.common,
searchEngine: {
...prev.settings.common.searchEngine,
properties: {
...prev.settings.common.searchEngine.properties,
enabled: !enabled,
},
} as SearchEngineCommonSettingsType,
},
},
}));
};
return (
<Switch checked={enabled} onChange={toggleEnabled} size="md" label={t('searchEnabled.label')} />
);
}

View File

@@ -0,0 +1,126 @@
import { Alert, Paper, SegmentedControl, Stack, TextInput, Title } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import {
CommonSearchEngineCommonSettingsType,
SearchEngineCommonSettingsType,
} from '../../../types/settings';
import Tip from '../../layout/Tip';
interface Props {
searchEngine: SearchEngineCommonSettingsType;
}
// TODO: discuss with @manuel-rw the design of the search engine
export const SearchEngineSelector = ({ searchEngine }: Props) => {
const { t } = useTranslation(['settings/general/search-engine']);
const { updateSearchEngineConfig } = useUpdateSearchEngineConfig();
const [engine, setEngine] = useState(searchEngine.type);
const [searchUrl, setSearchUrl] = useState(
searchEngine.type === 'custom' ? searchEngine.properties.template : searchUrls.google
);
const onEngineChange = (value: EngineType) => {
setEngine(value);
updateSearchEngineConfig(value, searchUrl);
};
const onSearchUrlChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const url = ev.currentTarget.value;
setSearchUrl(url);
updateSearchEngineConfig(engine, url);
};
return (
<Stack spacing={0} mt="xs">
<Title order={5} mb="xs">
{t('title')}
</Title>
<SegmentedControl
fullWidth
mb="sm"
title={t('title')}
value={engine}
onChange={onEngineChange}
data={searchEngineOptions}
/>
{engine === 'custom' && (
<Paper p="md" py="sm" mb="xs" withBorder>
<Title order={6}>{t('customEngine.title')}</Title>
<Tip>{t('tips.placeholderTip')}</Tip>
<TextInput
label={t('customEngine.label')}
placeholder={t('customEngine.placeholder')}
value={searchUrl}
onChange={onSearchUrlChange}
/>
</Paper>
)}
<Alert icon={<IconInfoCircle />} color="blue">
{t('tips.generalTip')}
</Alert>
</Stack>
);
};
const searchEngineOptions: { label: string; value: EngineType }[] = [
{ label: 'Google', value: 'google' },
{ label: 'DuckDuckGo', value: 'duckDuckGo' },
{ label: 'Bing', value: 'bing' },
{ label: 'Custom', value: 'custom' },
];
export const searchUrls: { [key in CommonSearchEngineCommonSettingsType['type']]: string } = {
google: 'https://google.com/search?q=',
duckDuckGo: 'https://duckduckgo.com/?q=',
bing: 'https://bing.com/search?q=',
};
type EngineType = SearchEngineCommonSettingsType['type'];
const useUpdateSearchEngineConfig = () => {
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
if (!configName)
return {
updateSearchEngineConfig: () => {},
};
const updateSearchEngineConfig = (engine: EngineType, searchUrl: string) => {
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
common: {
...prev.settings.common,
searchEngine:
engine === 'custom'
? {
type: engine,
properties: {
...prev.settings.common.searchEngine.properties,
template: searchUrl,
},
}
: {
type: engine,
properties: {
openInNewTab: prev.settings.common.searchEngine.properties.openInNewTab,
enabled: prev.settings.common.searchEngine.properties.enabled,
},
},
},
},
}));
};
return {
updateSearchEngineConfig,
};
};

View File

@@ -0,0 +1,49 @@
import { Switch } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { SearchEngineCommonSettingsType } from '../../../types/settings';
interface SearchNewTabSwitchProps {
defaultValue: boolean | undefined;
}
export function SearchNewTabSwitch({ defaultValue }: SearchNewTabSwitchProps) {
const { t } = useTranslation('settings/general/search-engine');
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const [openInNewTab, setOpenInNewTab] = useState<boolean>(defaultValue ?? true);
if (!configName) return null;
const toggleOpenInNewTab = () => {
setOpenInNewTab(!openInNewTab);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
common: {
...prev.settings.common,
searchEngine: {
...prev.settings.common.searchEngine,
properties: {
...prev.settings.common.searchEngine.properties,
openInNewTab: !openInNewTab,
},
} as SearchEngineCommonSettingsType,
},
},
}));
};
return (
<Switch
checked={openInNewTab}
onChange={toggleOpenInNewTab}
size="md"
label={t('searchNewTab.label')}
/>
);
}

View File

@@ -1,89 +1,34 @@
import { Text, SegmentedControl, TextInput, Stack } from '@mantine/core';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { SearchNewTabSwitch } from '../SearchNewTabSwitch/SearchNewTabSwitch';
import { ColorSchemeSwitch } from '../ColorSchemeToggle/ColorSchemeSwitch';
import { WidgetsPositionSwitch } from '../WidgetsPositionSwitch/WidgetsPositionSwitch';
import { Space, Stack, Text } from '@mantine/core';
import { useConfigContext } from '../../config/provider';
import ConfigChanger from '../Config/ConfigChanger';
import SaveConfigComponent from '../Config/SaveConfig';
import ModuleEnabler from './ModuleEnabler';
import Tip from '../layout/Tip';
import LanguageSwitch from './LanguageSwitch';
import ConfigActions from './Common/ConfigActions';
import LanguageSelect from './Common/LanguageSelect';
import { SearchEnabledSwitch } from './Common/SearchEngineEnabledSwitch';
import { SearchEngineSelector } from './Common/SearchEngineSelector';
import { SearchNewTabSwitch } from './Common/SearchNewTabSwitch';
export default function CommonSettings(args: any) {
const { config, setConfig } = useConfig();
const { t } = useTranslation(['settings/general/search-engine', 'settings/common']);
export default function CommonSettings() {
const { config } = useConfigContext();
const matches = [
{ label: 'Google', value: 'https://google.com/search?q=' },
{ label: 'DuckDuckGo', value: 'https://duckduckgo.com/?q=' },
{ label: 'Bing', value: 'https://bing.com/search?q=' },
{ label: 'Custom', value: 'Custom' },
];
const [customSearchUrl, setCustomSearchUrl] = useState(config.settings.searchUrl);
const [searchUrl, setSearchUrl] = useState(
matches.find((match) => match.value === config.settings.searchUrl)?.value ?? 'Custom'
);
if (!config) {
return (
<Text color="red" align="center">
No active config
</Text>
);
}
return (
<Stack mb="md" mr="sm">
<Stack spacing={0} mt="xs">
<Text>{t('title')}</Text>
<Tip>{t('tips.generalTip')}</Tip>
<SegmentedControl
fullWidth
mb="sm"
title={t('title')}
value={
// Match config.settings.searchUrl with a key in the matches array
searchUrl
}
onChange={
// Set config.settings.searchUrl to the value of the selected item
(e) => {
setSearchUrl(e);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: e,
},
});
}
}
data={matches}
/>
{searchUrl === 'Custom' && (
<>
<Tip>{t('tips.placeholderTip')}</Tip>
<TextInput
label={t('customEngine.label')}
placeholder={t('customEngine.placeholder')}
value={customSearchUrl}
onChange={(event) => {
setCustomSearchUrl(event.currentTarget.value);
setConfig({
...config,
settings: {
...config.settings,
searchUrl: event.currentTarget.value,
},
});
}}
/>
</>
)}
</Stack>
<SearchNewTabSwitch />
<ColorSchemeSwitch />
<WidgetsPositionSwitch />
<ModuleEnabler />
<LanguageSwitch />
<SearchEngineSelector searchEngine={config.settings.common.searchEngine} />
<SearchNewTabSwitch
defaultValue={config.settings.common.searchEngine.properties.openInNewTab}
/>
<SearchEnabledSwitch defaultValue={config.settings.common.searchEngine.properties.enabled} />
<Space />
<LanguageSelect />
<ConfigChanger />
<SaveConfigComponent />
<Tip>{t('settings/common:tips.configTip')}</Tip>
<ConfigActions />
</Stack>
);
}

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
import { CURRENT_VERSION } from '../../../data/constants';
export default function Credits(props: any) {
export default function Credits() {
const { t } = useTranslation('settings/common');
return (

View File

@@ -0,0 +1,43 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
interface BackgroundChangerProps {
defaultValue: string | undefined;
}
export const BackgroundChanger = ({ defaultValue }: BackgroundChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
const [backgroundImageUrl, setBackgroundImageUrl] = useState(defaultValue);
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const backgroundImageUrl = value.trim().length === 0 ? undefined : value;
setBackgroundImageUrl(backgroundImageUrl);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
backgroundImageUrl,
},
},
}));
};
return (
<TextInput
label={t('background.label')}
placeholder="/imgs/background.png"
value={backgroundImageUrl}
onChange={handleChange}
/>
);
};

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import {
ColorSwatch,
Grid,
Group,
MantineTheme,
Popover,
Text,
useMantineTheme,
} from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useColorTheme } from '../../../tools/color';
import { useDisclosure } from '@mantine/hooks';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../config/provider';
interface ColorControlProps {
defaultValue: MantineTheme['primaryColor'] | undefined;
type: 'primary' | 'secondary';
}
export function ColorSelector({ type, defaultValue }: ColorControlProps) {
const { t } = useTranslation('settings/customization/color-selector');
const [color, setColor] = useState(defaultValue);
const [popoverOpened, popover] = useDisclosure(false);
const { setPrimaryColor, setSecondaryColor } = useColorTheme();
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const theme = useMantineTheme();
const colors = Object.keys(theme.colors).map((color) => ({
swatch: theme.colors[color][6],
color,
}));
if (!color || !configName) return null;
const handleSelection = (color: MantineTheme['primaryColor']) => {
setColor(color);
if (type === 'primary') setPrimaryColor(color);
else setSecondaryColor(color);
updateConfig(configName, (prev) => {
const colors = prev.settings.customization.colors;
colors[type] = color;
return {
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
colors,
},
},
};
});
};
const swatches = colors.map(({ color, swatch }) => (
<Grid.Col span={2} key={color}>
<ColorSwatch
component="button"
type="button"
onClick={() => handleSelection(color)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
return (
<Group>
<Popover
width={250}
withinPortal
opened={popoverOpened}
onClose={popover.close}
position="left"
withArrow
>
<Popover.Target>
<ColorSwatch
component="button"
type="button"
color={theme.colors[color][6]}
onClick={popover.toggle}
size={22}
style={{ cursor: 'pointer' }}
/>
</Popover.Target>
<Popover.Dropdown>
<Grid gutter="lg" columns={14}>
{swatches}
</Grid>
</Popover.Dropdown>
</Popover>
<Text>
{t('suffix', {
color: type[0].toUpperCase() + type.slice(1),
})}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,44 @@
import { Textarea } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
interface CustomCssChangerProps {
defaultValue: string | undefined;
}
export const CustomCssChanger = ({ defaultValue }: CustomCssChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
const [customCss, setCustomCss] = useState(defaultValue);
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (ev) => {
const value = ev.currentTarget.value;
const customCss = value.trim().length === 0 ? undefined : value;
setCustomCss(customCss);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
customCss,
},
},
}));
};
return (
<Textarea
minRows={5}
label={t('customCSS.label')}
placeholder={t('customCSS.placeholder')}
value={customCss}
onChange={handleChange}
/>
);
};

View File

@@ -0,0 +1,43 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
interface FaviconChangerProps {
defaultValue: string | undefined;
}
export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
const [faviconUrl, setFaviconUrl] = useState(defaultValue);
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const faviconUrl = value.trim().length === 0 ? undefined : value;
setFaviconUrl(faviconUrl);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
faviconUrl,
},
},
}));
};
return (
<TextInput
label={t('favicon.label')}
placeholder="/imgs/favicon/favicon.svg"
value={faviconUrl}
onChange={handleChange}
/>
);
};

View File

@@ -0,0 +1,164 @@
import {
Box,
Center,
Checkbox,
createStyles,
Group,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { IconBrandDocker, IconLayout, IconSearch } from '@tabler/icons';
import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { CustomizationSettingsType } from '../../../types/settings';
import { Logo } from '../../layout/Logo';
interface LayoutSelectorProps {
defaultLayout: CustomizationSettingsType['layout'] | undefined;
}
// TODO: add translations
export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
const { classes } = useStyles();
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const [leftSidebar, setLeftSidebar] = useState(defaultLayout?.enabledLeftSidebar ?? true);
const [rightSidebar, setRightSidebar] = useState(defaultLayout?.enabledRightSidebar ?? true);
const [docker, setDocker] = useState(defaultLayout?.enabledDocker ?? false);
const [ping, setPing] = useState(defaultLayout?.enabledPing ?? false);
const [searchBar, setSearchBar] = useState(defaultLayout?.enabledSearchbar ?? false);
if (!configName) return null;
const handleChange = (
key: keyof CustomizationSettingsType['layout'],
event: ChangeEvent<HTMLInputElement>,
setState: Dispatch<SetStateAction<boolean>>
) => {
const value = event.target.checked;
setState(value);
updateConfig(configName, (prev) => {
const layout = prev.settings.customization.layout;
layout[key] = value;
return {
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
layout,
},
},
};
});
};
return (
<Box className={classes.box} p="xl" pb="sm">
<Stack spacing="xs">
<Group spacing={5}>
<IconLayout size={20} />
<Title order={6}>Dashboard layout</Title>
</Group>
<Text color="dimmed" size="sm">
You can adjust the layout of the Dashboard to your preferences. The main are cannot be
turned on or off
</Text>
<Paper px="xs" py={2} withBorder>
<Group position="apart">
<Logo size="xs" />
<Group spacing={4}>
{searchBar ? (
<Paper withBorder p={2} w={60}>
<Group spacing={2} align="center">
<IconSearch size={8} />
<Text size={8} color="dimmed">
Search
</Text>
</Group>
</Paper>
) : null}
{docker ? <IconBrandDocker size={18} color="#0db7ed" /> : null}
</Group>
</Group>
</Paper>
<Group align="stretch">
{leftSidebar && (
<Paper p="xs" withBorder>
<Center style={{ height: '100%' }}>
<Text align="center">Sidebar</Text>
</Center>
</Paper>
)}
<Paper className={classes.main} p="xs" withBorder>
<Text align="center">Main</Text>
<Text color="dimmed" size="xs" align="center">
Can be used for categories,
<br />
services and integrations
</Text>
</Paper>
{rightSidebar && (
<Paper p="xs" withBorder>
<Center style={{ height: '100%' }}>
<Text align="center">Sidebar</Text>
</Center>
</Paper>
)}
</Group>
<Stack spacing="xs">
<Checkbox
label="Enable left sidebar"
description="Optional. Can be used for services and integrations only"
checked={leftSidebar}
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
/>
<Checkbox
label="Enable right sidebar"
description="Optional. Can be used for services and integrations only"
checked={rightSidebar}
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
/>
<Checkbox
label="Enable search bar"
checked={searchBar}
onChange={(ev) => handleChange('enabledSearchbar', ev, setSearchBar)}
/>
<Checkbox
label="Enable docker"
checked={docker}
onChange={(ev) => handleChange('enabledDocker', ev, setDocker)}
/>
<Checkbox
label="Enable pings"
checked={ping}
onChange={(ev) => handleChange('enabledPing', ev, setPing)}
/>
</Stack>
</Stack>
</Box>
);
};
const useStyles = createStyles((theme) => ({
main: {
flexGrow: 1,
},
box: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[1],
borderRadius: theme.radius.md,
},
}));

View File

@@ -0,0 +1,43 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
interface LogoImageChangerProps {
defaultValue: string | undefined;
}
export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
const [logoImageSrc, setLogoImageSrc] = useState(defaultValue);
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const logoImageSrc = value.trim().length === 0 ? undefined : value;
setLogoImageSrc(logoImageSrc);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
logoImageUrl: logoImageSrc,
},
},
}));
};
return (
<TextInput
label={t('logo.label')}
placeholder="/imgs/logo/logo.png"
value={logoImageSrc}
onChange={handleChange}
/>
);
};

View File

@@ -0,0 +1,43 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
interface MetaTitleChangerProps {
defaultValue: string | undefined;
}
export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
const [metaTitle, setMetaTitle] = useState(defaultValue);
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const metaTitle = value.trim().length === 0 ? undefined : value;
setMetaTitle(metaTitle);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
metaTitle,
},
},
}));
};
return (
<TextInput
label={t('metaTitle.label')}
placeholder={t('metaTitle.placeholder')}
value={metaTitle}
onChange={handleChange}
/>
);
};

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { Text, Slider, Stack } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
interface OpacitySelectorProps {
defaultValue: number | undefined;
}
export function OpacitySelector({ defaultValue }: OpacitySelectorProps) {
const [opacity, setOpacity] = useState(defaultValue || 100);
const { t } = useTranslation('settings/customization/opacity-selector');
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
if (!configName) return null;
const handleChange = (opacity: number) => {
setOpacity(opacity);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
appOpacity: opacity,
},
},
}));
};
return (
<Stack spacing="xs">
<Text>{t('label')}</Text>
<Slider
defaultValue={opacity}
step={10}
min={10}
marks={MARKS}
styles={{ markLabel: { fontSize: 'xx-small' } }}
onChange={handleChange}
/>
</Stack>
);
}
const MARKS = [
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 30, label: '30' },
{ value: 40, label: '40' },
{ value: 50, label: '50' },
{ value: 60, label: '60' },
{ value: 70, label: '70' },
{ value: 80, label: '80' },
{ value: 90, label: '90' },
{ value: 100, label: '100' },
];

View File

@@ -0,0 +1,43 @@
import { TextInput } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
interface PageTitleChangerProps {
defaultValue: string | undefined;
}
export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
const [pageTitle, setPageTitle] = useState(defaultValue);
if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const value = ev.currentTarget.value;
const pageTitle = value.trim().length === 0 ? undefined : value;
setPageTitle(pageTitle);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...prev.settings,
customization: {
...prev.settings.customization,
pageTitle,
},
},
}));
};
return (
<TextInput
label={t('pageTitle.label')}
placeholder={t('pageTitle.placeholder')}
value={pageTitle}
onChange={handleChange}
/>
);
};

View File

@@ -10,35 +10,48 @@ import {
Grid,
} from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { useColorTheme } from '../../tools/color';
import { useColorTheme } from '../../../tools/color';
import { useDisclosure } from '@mantine/hooks';
import { useConfigStore } from '../../../config/store';
import { useConfigContext } from '../../../config/provider';
export function ShadeSelector() {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
interface ShadeSelectorProps {
defaultValue: MantineTheme['primaryShade'] | undefined;
}
export function ShadeSelector({ defaultValue }: ShadeSelectorProps) {
const { t } = useTranslation('settings/customization/shade-selector');
const [shade, setShade] = useState(defaultValue);
const [popoverOpened, popover] = useDisclosure(false);
const { primaryColor, setPrimaryShade } = useColorTheme();
const { primaryColor, secondaryColor, primaryShade, setPrimaryShade } = useColorTheme();
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const theme = useMantineTheme();
const primaryShades = theme.colors[primaryColor].map((s, i) => ({
swatch: theme.colors[primaryColor][i],
shade: i as MantineTheme['primaryShade'],
}));
const secondaryShades = theme.colors[secondaryColor].map((s, i) => ({
swatch: theme.colors[secondaryColor][i],
shade: i as MantineTheme['primaryShade'],
}));
const setConfigShade = (shade: MantineTheme['primaryShade']) => {
if (shade === undefined || !configName) return null;
const handleSelection = (shade: MantineTheme['primaryShade']) => {
setPrimaryShade(shade);
setConfig({
...config,
setShade(shade);
updateConfig(configName, (prev) => ({
...prev,
settings: {
...config.settings,
primaryShade: shade,
...prev.settings,
customization: {
...prev.settings.customization,
colors: {
...prev.settings.customization.colors,
shade,
},
},
},
});
}));
};
const primarySwatches = primaryShades.map(({ swatch, shade }) => (
@@ -46,20 +59,7 @@ export function ShadeSelector() {
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
/>
</Grid.Col>
));
const secondarySwatches = secondaryShades.map(({ swatch, shade }) => (
<Grid.Col span={1} key={Number(shade)}>
<ColorSwatch
component="button"
type="button"
onClick={() => setConfigShade(shade)}
onClick={() => handleSelection(shade)}
color={swatch}
size={22}
style={{ cursor: 'pointer' }}
@@ -72,8 +72,8 @@ export function ShadeSelector() {
<Popover
width={350}
withinPortal
opened={opened}
onClose={() => setOpened(false)}
opened={popoverOpened}
onClose={popover.close}
position="left"
withArrow
>
@@ -81,8 +81,8 @@ export function ShadeSelector() {
<ColorSwatch
component="button"
type="button"
color={theme.colors[primaryColor][Number(primaryShade)]}
onClick={() => setOpened((o) => !o)}
color={theme.colors[primaryColor][Number(shade)]}
onClick={popover.toggle}
size={22}
style={{ display: 'block', cursor: 'pointer' }}
/>
@@ -91,7 +91,6 @@ export function ShadeSelector() {
<Stack spacing="xs">
<Grid gutter="lg" columns={10}>
{primarySwatches}
{secondarySwatches}
</Grid>
</Stack>
</Popover.Dropdown>

View File

@@ -0,0 +1,35 @@
import { Stack } from '@mantine/core';
import { useConfigContext } from '../../config/provider';
import { ColorSelector } from './Customization/ColorSelector';
import { BackgroundChanger } from './Customization/BackgroundChanger';
import { CustomCssChanger } from './Customization/CustomCssChanger';
import { FaviconChanger } from './Customization/FaviconChanger';
import { LogoImageChanger } from './Customization/LogoImageChanger';
import { MetaTitleChanger } from './Customization/MetaTitleChanger';
import { PageTitleChanger } from './Customization/PageTitleChanger';
import { OpacitySelector } from './Customization/OpacitySelector';
import { ShadeSelector } from './Customization/ShadeSelector';
import { LayoutSelector } from './Customization/LayoutSelector';
export default function CustomizationSettings() {
const { config } = useConfigContext();
return (
<Stack mb="md" mr="sm" mt="xs">
<LayoutSelector defaultLayout={config?.settings.customization.layout} />
<PageTitleChanger defaultValue={config?.settings.customization.pageTitle} />
<MetaTitleChanger defaultValue={config?.settings.customization.metaTitle} />
<LogoImageChanger defaultValue={config?.settings.customization.logoImageUrl} />
<FaviconChanger defaultValue={config?.settings.customization.faviconUrl} />
<BackgroundChanger defaultValue={config?.settings.customization.backgroundImageUrl} />
<CustomCssChanger defaultValue={config?.settings.customization.customCss} />
<ColorSelector type="primary" defaultValue={config?.settings.customization.colors.primary} />
<ColorSelector
type="secondary"
defaultValue={config?.settings.customization.colors.secondary}
/>
<ShadeSelector defaultValue={config?.settings.customization.colors.shade} />
<OpacitySelector defaultValue={config?.settings.customization.appOpacity} />
</Stack>
);
}

View File

@@ -1,30 +0,0 @@
import { Switch } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfig } from '../../tools/state';
export function GrowthSelector() {
const { config, setConfig } = useConfig();
const defaultPosition = config?.settings?.grow || false;
const [growState, setGrowState] = useState(defaultPosition);
const { t } = useTranslation('settings/common.json');
const toggleGrowState = () => {
setGrowState(!growState);
setConfig({
...config,
settings: {
...config.settings,
grow: !growState,
},
});
};
return (
<Switch
label={t('settings/common:grow')}
checked={growState === true}
onChange={() => toggleGrowState()}
size="md"
/>
);
}

View File

@@ -1,56 +0,0 @@
import { Checkbox, HoverCard, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import * as Modules from '../../modules';
import { IModule } from '../../modules/ModuleTypes';
import { useConfig } from '../../tools/state';
export default function ModuleEnabler(props: any) {
const { t } = useTranslation('settings/general/module-enabler');
const modules = Object.values(Modules).map((module) => module);
return (
<Stack>
<Title order={4}>{t('title')}</Title>
<SimpleGrid cols={3} spacing="sm">
{modules.map((module) => (
<ModuleToggle key={module.id} module={module} />
))}
</SimpleGrid>
</Stack>
);
}
const ModuleToggle = ({ module }: { module: IModule }) => {
const { config, setConfig } = useConfig();
const { t } = useTranslation(`modules/${module.id}`);
return (
<HoverCard withArrow withinPortal width={200} shadow="md" openDelay={200}>
<HoverCard.Target>
<Checkbox
key={module.id}
size="md"
checked={config.modules?.[module.id]?.enabled ?? false}
label={t('descriptor.name', {
defaultValue: 'Unknown',
})}
onChange={(e) => {
setConfig({
...config,
modules: {
...config.modules,
[module.id]: {
...config.modules?.[module.id],
enabled: e.currentTarget.checked,
},
},
});
}}
/>
</HoverCard.Target>
<HoverCard.Dropdown>
<Title order={4}>{t('descriptor.name')}</Title>
<Text size="sm">{t('descriptor.description')}</Text>
</HoverCard.Dropdown>
</HoverCard>
);
};

View File

@@ -1,46 +0,0 @@
import React from 'react';
import { Text, Slider, Stack } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
export function OpacitySelector() {
const { config, setConfig } = useConfig();
const { t } = useTranslation('settings/customization/opacity-selector');
const MARKS = [
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 30, label: '30' },
{ value: 40, label: '40' },
{ value: 50, label: '50' },
{ value: 60, label: '60' },
{ value: 70, label: '70' },
{ value: 80, label: '80' },
{ value: 90, label: '90' },
{ value: 100, label: '100' },
];
const setConfigOpacity = (opacity: number) => {
setConfig({
...config,
settings: {
...config.settings,
appOpacity: opacity,
},
});
};
return (
<Stack spacing="xs">
<Text>{t('label')}</Text>
<Slider
defaultValue={config.settings.appOpacity || 100}
step={10}
min={10}
marks={MARKS}
styles={{ markLabel: { fontSize: 'xx-small' } }}
onChange={(value) => setConfigOpacity(value)}
/>
</Stack>
);
}

View File

@@ -4,27 +4,27 @@ import { useState } from 'react';
import { IconSettings } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import AdvancedSettings from './AdvancedSettings';
import CustomizationSettings from './CustomizationSettings';
import CommonSettings from './CommonSettings';
import Credits from './Credits';
function SettingsMenu(props: any) {
function SettingsMenu() {
const { t } = useTranslation('settings/common');
return (
<Tabs defaultValue="Common">
<Tabs defaultValue="common">
<Tabs.List grow>
<Tabs.Tab value="Common">{t('tabs.common')}</Tabs.Tab>
<Tabs.Tab value="Customizations">{t('tabs.customizations')}</Tabs.Tab>
<Tabs.Tab value="common">{t('tabs.common')}</Tabs.Tab>
<Tabs.Tab value="customization">{t('tabs.customizations')}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel data-autofocus value="Common">
<Tabs.Panel data-autofocus value="common">
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<CommonSettings />
</ScrollArea>
</Tabs.Panel>
<Tabs.Panel value="Customizations">
<Tabs.Panel value="customization">
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<AdvancedSettings />
<CustomizationSettings />
</ScrollArea>
</Tabs.Panel>
</Tabs>

View File

@@ -1,58 +0,0 @@
import React, { useState } from 'react';
import { createStyles, Switch, Group } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
const useStyles = createStyles((theme) => ({
root: {
position: 'relative',
'& *': {
cursor: 'pointer',
},
},
icon: {
pointerEvents: 'none',
position: 'absolute',
zIndex: 1,
top: 3,
},
iconLight: {
left: 4,
color: theme.white,
},
iconDark: {
right: 4,
color: theme.colors.gray[6],
},
}));
export function WidgetsPositionSwitch() {
const { config, setConfig } = useConfig();
const { classes, cx } = useStyles();
const defaultPosition = config?.settings?.widgetPosition || 'right';
const [widgetPosition, setWidgetPosition] = useState(defaultPosition);
const { t } = useTranslation('settings/general/widget-positions');
const toggleWidgetPosition = () => {
const position = widgetPosition === 'right' ? 'left' : 'right';
setWidgetPosition(position);
setConfig({
...config,
settings: {
...config.settings,
widgetPosition: position,
},
});
};
return (
<Switch
label={t('label')}
checked={widgetPosition === 'left'}
onChange={() => toggleWidgetPosition()}
size="md"
/>
);
}

View File

@@ -1,15 +1,15 @@
import { Global } from '@mantine/core';
import { useConfig } from '../../tools/state';
import { useConfigContext } from '../../config/provider';
export function Background() {
const { config } = useConfig();
const { config } = useConfigContext();
return (
<Global
styles={{
body: {
minHeight: '100vh',
backgroundImage: `url('${config.settings.background}')` || '',
backgroundImage: `url('${config?.settings.customization.backgroundImageUrl}')` || '',
backgroundPosition: 'center center',
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',

View File

@@ -0,0 +1,34 @@
/* eslint-disable react/no-invalid-html-attribute */
import React from 'react';
import NextHead from 'next/head';
import { SafariStatusBarStyle } from './SafariStatusBarStyle';
import { useConfigContext } from '../../../config/provider';
export function Head() {
const { config } = useConfigContext();
return (
<NextHead>
<title>{config?.settings.customization.metaTitle || 'Homarr 🦞'}</title>
<link
rel="shortcut icon"
href={config?.settings.customization.faviconUrl || '/imgs/favicon/favicon.svg'}
/>
<link rel="manifest" href="/site.webmanifest" />
{/* configure apple splash screen & touch icon */}
<link
rel="apple-touch-icon"
href={config?.settings.customization.faviconUrl || '/imgs/favicon/favicon-squared.png'}
/>
<meta
name="apple-mobile-web-app-title"
content={config?.settings.customization.metaTitle || 'Homarr'}
/>
<SafariStatusBarStyle />
<meta name="apple-mobile-web-app-capable" content="yes" />
</NextHead>
);
}

View File

@@ -0,0 +1,12 @@
import { useMantineTheme } from '@mantine/core';
export const SafariStatusBarStyle = () => {
const { colorScheme } = useMantineTheme();
const isDark = colorScheme === 'dark';
return (
<meta
name="apple-mobile-web-app-status-bar-style"
content={isDark ? 'white-translucent' : 'black-translucent'}
/>
);
};

View File

@@ -1,48 +1,31 @@
import { AppShell, createStyles } from '@mantine/core';
import { Header } from './header/Header';
import { Footer } from './Footer';
import Aside from './Aside';
import Navbar from './Navbar';
import { HeaderConfig } from './header/HeaderConfig';
import { useConfigContext } from '../../config/provider';
import { Background } from './Background';
import { useConfig } from '../../tools/state';
import { Footer } from './Footer';
import { Header } from './Header/Header';
import { Head } from './Head/Head';
const useStyles = createStyles((theme) => ({
main: {},
appShell: {
// eslint-disable-next-line no-useless-computed-key
['@media screen and (display-mode: standalone)']: {
'&': {
paddingTop: '88px !important',
},
},
},
}));
const useStyles = createStyles(() => ({}));
export default function Layout({ children, style }: any) {
const { classes, cx } = useStyles();
const { config } = useConfig();
const widgetPosition = config?.settings?.widgetPosition === 'left';
export default function Layout({ children }: any) {
const { cx } = useStyles();
const { config } = useConfigContext();
return (
<AppShell
fixed={false}
header={<Header />}
navbar={widgetPosition ? <Navbar /> : undefined}
aside={widgetPosition ? undefined : <Aside />}
footer={<Footer links={[]} />}
styles={{
main: {
minHeight: 'calc(100vh - var(--mantine-header-height))',
},
}}
>
<HeaderConfig />
<Head />
<Background />
<main
className={cx(classes.main)}
style={{
...style,
}}
>
{children}
</main>
<style>{cx(config.settings.customCSS)}</style>
{children}
<style>{cx(config?.settings.customization.customCss)}</style>
</AppShell>
);
}

View File

@@ -1,25 +1,28 @@
import { Group, Image, Text } from '@mantine/core';
import { NextLink } from '@mantine/next';
import * as React from 'react';
import { useConfigContext } from '../../config/provider';
import { useColorTheme } from '../../tools/color';
import { useConfig } from '../../tools/state';
export function Logo({ style, withoutText }: any) {
const { config } = useConfig();
interface LogoProps {
size?: 'md' | 'xs';
withoutText?: boolean;
}
export function Logo({ size = 'md', withoutText = false }: LogoProps) {
const { config } = useConfigContext();
const { primaryColor, secondaryColor } = useColorTheme();
return (
<Group spacing="xs" noWrap>
<Group spacing={size === 'md' ? 'xs' : 4} noWrap>
<Image
width={50}
src={config.settings.logo || '/imgs/logo/logo.png'}
width={size === 'md' ? 50 : 20}
src={config?.settings.customization.logoImageUrl || '/imgs/logo/logo.png'}
style={{
position: 'relative',
}}
/>
{withoutText ? null : (
<Text
sx={style}
size={size === 'md' ? 22 : 'xs'}
weight="bold"
variant="gradient"
gradient={{
@@ -28,7 +31,7 @@ export function Logo({ style, withoutText }: any) {
deg: 145,
}}
>
{config.settings.title || 'Homarr'}
{config?.settings.customization.pageTitle || 'Homarr'}
</Text>
)}
</Group>

View File

@@ -1,38 +1,40 @@
import { Group, Header as Head, useMantineColorScheme, useMantineTheme } from '@mantine/core';
import { Box, createStyles, Group, Header as MantineHeader, useMantineTheme } from '@mantine/core';
import { useViewportSize } from '@mantine/hooks';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import DockerMenuButton from '../../../modules/docker/DockerModule';
import { Search } from './Search';
import { SettingsMenuButton } from '../../Settings/SettingsMenu';
import { Logo } from '../Logo';
import { useConfig } from '../../../tools/state';
import { SearchModuleComponent } from '../../../modules/search/SearchModule';
import { useCardStyles } from '../useCardStyles';
export const HeaderHeight = 64;
export function Header(props: any) {
const { width } = useViewportSize();
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const { config } = useConfig();
const { colorScheme } = useMantineColorScheme();
const { classes } = useStyles();
const { classes: cardClasses } = useCardStyles();
return (
<Head
height="auto"
style={{
background: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '255, 255, 255,'} \
${(config.settings.appOpacity || 100) / 100}`,
borderColor: `rgba(${colorScheme === 'dark' ? '37, 38, 43,' : '233, 236, 239,'} \
${(config.settings.appOpacity || 100) / 100}`,
}}
>
<MantineHeader height={HeaderHeight} className={cardClasses.card}>
<Group p="xs" noWrap grow>
{width > MIN_WIDTH_MOBILE && <Logo style={{ fontSize: 22 }} />}
<Box className={classes.hide}>
<Logo />
</Box>
<Group position="right" noWrap>
<SearchModuleComponent />
<Search />
<DockerMenuButton />
<SettingsMenuButton />
<AddItemShelfButton />
</Group>
</Group>
</Head>
</MantineHeader>
);
}
const useStyles = createStyles((theme) => ({
hide: {
[theme.fn.smallerThan('xs')]: {
display: 'none',
},
},
}));

View File

@@ -1,28 +0,0 @@
/* eslint-disable react/no-invalid-html-attribute */
import React from 'react';
import Head from 'next/head';
import { useConfig } from '../../../tools/state';
import { SafariStatusBarStyle } from './safariStatusBarStyle';
export function HeaderConfig(props: any) {
const { config } = useConfig();
return (
<Head>
<title>{config.settings.title || 'Homarr 🦞'}</title>
<link rel="shortcut icon" href={config.settings.favicon || '/imgs/favicon/favicon.svg'} />
<link rel="manifest" href="/site.webmanifest" />
{/* configure apple splash screen & touch icon */}
<link
rel="apple-touch-icon"
href={config.settings.favicon || '/imgs/favicon/favicon-squared.png'}
/>
<meta name="apple-mobile-web-app-title" content={config.settings.title || 'Homarr'} />
<SafariStatusBarStyle />
<meta name="apple-mobile-web-app-capable" content="yes" />
</Head>
);
}

View File

@@ -0,0 +1,284 @@
import {
ActionIcon,
Autocomplete,
Box,
createStyles,
Divider,
Kbd,
Menu,
Popover,
ScrollArea,
Tooltip,
} from '@mantine/core';
import { useDebouncedValue, useHotkeys } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/icons';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import SmallServiceItem from '../../AppShelf/SmallServiceItem';
import Tip from '../Tip';
import { searchUrls } from '../../Settings/Common/SearchEngineSelector';
import { useConfigContext } from '../../../config/provider';
import { OverseerrMediaDisplay } from '../../../modules/common';
import { IModule } from '../../../modules/ModuleTypes';
export const SearchModule: IModule = {
title: 'Search',
icon: IconSearch,
component: Search,
id: 'search',
};
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
label: string;
disabled: boolean;
value: string;
description: string;
icon: React.ReactNode;
url: string;
shortcut: string;
}
const useStyles = createStyles((theme) => ({
item: {
'&[data-hovered]': {
backgroundColor: theme.colors[theme.primaryColor][theme.fn.primaryShade()],
color: theme.white,
},
},
}));
export function Search() {
const { t } = useTranslation('modules/search');
const { config } = useConfigContext();
const [searchQuery, setSearchQuery] = useState('');
const [debounced, cancel] = useDebouncedValue(searchQuery, 250);
// TODO: ask manuel-rw about overseerr
const isOverseerrEnabled = false; //config?.settings.common.enabledModules.overseerr;
const overseerrService = config?.services.find(
(service) =>
service.integration?.type === 'overseerr' || service.integration?.type === 'jellyseerr'
);
const searchEngineSettings = config?.settings.common.searchEngine;
const searchEngineUrl = !searchEngineSettings
? searchUrls.google
: searchEngineSettings.type === 'custom'
? searchEngineSettings.properties.template
: searchUrls[searchEngineSettings.type];
const searchEnginesList: ItemProps[] = [
{
icon: <IconSearch />,
disabled: false,
label: t('searchEngines.search.name'),
value: 'search',
description: t('searchEngines.search.description'),
url: searchEngineUrl,
shortcut: 's',
},
{
icon: <IconDownload />,
disabled: false,
label: t('searchEngines.torrents.name'),
value: 'torrents',
description: t('searchEngines.torrents.description'),
url: 'https://www.torrentdownloads.me/search/?search=',
shortcut: 't',
},
{
icon: <IconBrandYoutube />,
disabled: false,
label: t('searchEngines.youtube.name'),
value: 'youtube',
description: t('searchEngines.youtube.description'),
url: 'https://www.youtube.com/results?search_query=',
shortcut: 'y',
},
{
icon: <IconMovie />,
disabled: !(isOverseerrEnabled === true && overseerrService !== undefined),
label: t('searchEngines.overseerr.name'),
value: 'overseerr',
description: t('searchEngines.overseerr.description'),
url: `${overseerrService?.url}search?query=`,
shortcut: 'm',
},
];
const [selectedSearchEngine, setSearchEngine] = useState<ItemProps>(searchEnginesList[0]);
const matchingServices =
config?.services.filter((service) => {
if (searchQuery === '' || searchQuery === undefined) {
return false;
}
return service.name.toLowerCase().includes(searchQuery.toLowerCase());
}) ?? [];
const autocompleteData = matchingServices.map((service) => ({
label: service.name,
value: service.name,
icon: service.appearance.iconUrl,
url: service.behaviour.onClickUrl ?? service.url,
}));
const AutoCompleteItem = forwardRef<HTMLDivElement, any>(
({ label, value, icon, url, ...others }: any, ref) => (
<div ref={ref} {...others}>
<SmallServiceItem service={{ label, value, icon, url }} />
</div>
)
);
useEffect(() => {
// Refresh the default search engine every time the config for it changes #521
setSearchEngine(searchEnginesList[0]);
}, [searchEngineUrl]);
const textInput = useRef<HTMLInputElement>(null);
useHotkeys([['mod+K', () => textInput.current?.focus()]]);
const { classes } = useStyles();
const openInNewTab = config?.settings.common.searchEngine.properties.openInNewTab
? '_blank'
: '_self';
const [OverseerrResults, setOverseerrResults] = useState<any[]>([]);
const [opened, setOpened] = useState(false);
useEffect(() => {
if (debounced !== '' && selectedSearchEngine.value === 'overseerr' && searchQuery.length > 3) {
axios.get(`/api/modules/overseerr?query=${searchQuery}`).then((res) => {
setOverseerrResults(res.data.results ?? []);
});
}
}, [debounced]);
const isModuleEnabled = config?.settings.customization.layout.enabledSearchbar;
if (!isModuleEnabled) {
return null;
}
//TODO: Fix the bug where clicking anything inside the Modal to ask for a movie
// will close it (Because it closes the underlying Popover)
return (
<Box style={{ width: '100%', maxWidth: 400 }}>
<Popover
opened={OverseerrResults.length > 0 && opened && searchQuery.length > 3}
position="bottom"
withinPortal
shadow="md"
radius="md"
zIndex={100}
transition="pop-top-right"
>
<Popover.Target>
<Autocomplete
ref={textInput}
onFocusCapture={() => setOpened(true)}
autoFocus
rightSection={<SearchModuleMenu />}
placeholder={t(`searchEngines.${selectedSearchEngine.value}.description`)}
value={searchQuery}
onChange={(currentString) => tryMatchSearchEngine(currentString, setSearchQuery)}
itemComponent={AutoCompleteItem}
data={autocompleteData}
onItemSubmit={(item) => {
setOpened(false);
if (item.url) {
setSearchQuery('');
window.open(item.openedUrl ? item.openedUrl : item.url, openInNewTab);
}
}}
// Replace %s if it is in selectedSearchEngine.url with searchQuery, otherwise append searchQuery at the end of it
onKeyDown={(event) => {
if (
event.key === 'Enter' &&
searchQuery.length > 0 &&
autocompleteData.length === 0
) {
if (selectedSearchEngine.url.includes('%s')) {
window.open(selectedSearchEngine.url.replace('%s', searchQuery), openInNewTab);
} else {
window.open(selectedSearchEngine.url + searchQuery, openInNewTab);
}
}
}}
radius="md"
size="md"
/>
</Popover.Target>
<Popover.Dropdown>
<div>
<ScrollArea style={{ height: 400, width: 420 }} offsetScrollbars>
{OverseerrResults.slice(0, 5).map((result, index) => (
<React.Fragment key={index}>
<OverseerrMediaDisplay key={result.id} media={result} />
{index < OverseerrResults.length - 1 && <Divider variant="dashed" my="xl" />}
</React.Fragment>
))}
</ScrollArea>
</div>
</Popover.Dropdown>
</Popover>
</Box>
);
function tryMatchSearchEngine(query: string, setSearchQuery: (value: string) => void) {
const foundSearchEngine = searchEnginesList.find(
(engine) => query.includes(`!${engine.shortcut}`) && !engine.disabled
);
if (foundSearchEngine) {
setSearchQuery(query.replace(`!${foundSearchEngine.shortcut}`, ''));
changeSearchEngine(foundSearchEngine);
} else {
setSearchQuery(query);
}
}
function SearchModuleMenu() {
return (
<Menu shadow="md" width={200} withinPortal classNames={classes}>
<Menu.Target>
<ActionIcon>{selectedSearchEngine.icon}</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{searchEnginesList.map((item) => (
<Tooltip
multiline
label={item.description}
withinPortal
width={200}
position="left"
key={item.value}
>
<Menu.Item
key={item.value}
icon={item.icon}
rightSection={<Kbd>!{item.shortcut}</Kbd>}
disabled={item.disabled}
onClick={() => {
changeSearchEngine(item);
}}
>
{item.label}
</Menu.Item>
</Tooltip>
))}
<Menu.Divider />
<Menu.Label>
<Tip>
{t('tip')} <Kbd>mod+k</Kbd>{' '}
</Tip>
</Menu.Label>
</Menu.Dropdown>
</Menu>
);
}
function changeSearchEngine(item: ItemProps) {
setSearchEngine(item);
showNotification({
radius: 'lg',
disallowClose: true,
id: 'spotlight',
autoClose: 1000,
icon: <ActionIcon size="sm">{item.icon}</ActionIcon>,
message: t('switchedSearchEngine', { searchEngine: t(`searchEngines.${item.value}.name`) }),
});
}
}

View File

@@ -1,13 +0,0 @@
import { useMantineTheme } from '@mantine/core';
export const SafariStatusBarStyle = () => {
const colorScheme = useMantineTheme();
const isDark = colorScheme.colorScheme === 'dark';
if (isDark) {
return <meta name="apple-mobile-web-app-status-bar-style" content="white-translucent" />;
}
return <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />;
};

View File

@@ -0,0 +1,22 @@
import { createStyles } from '@mantine/core';
import { useConfigContext } from '../../config/provider';
export const useCardStyles = () => {
const { config } = useConfigContext();
const appOpacity = config?.settings.customization.appOpacity;
return createStyles(({ colorScheme }, _params) => {
const opacity = (appOpacity || 100) / 100;
return {
card: {
backgroundColor:
colorScheme === 'dark'
? `rgba(37, 38, 43, ${opacity}) !important`
: `rgba(255, 255, 255, ${opacity}) !important`,
borderColor:
colorScheme === 'dark'
? `rgba(37, 38, 43, ${opacity})`
: `rgba(233, 236, 239, ${opacity})`,
},
};
})();
};