🔀 Merge branch 'dev' into next-13

This commit is contained in:
Manuel
2023-01-31 22:21:15 +01:00
110 changed files with 1900 additions and 566 deletions

View File

@@ -13,6 +13,7 @@ import {
Title,
} from '@mantine/core';
import {
IconAnchor,
IconBrandDiscord,
IconBrandGithub,
IconFile,
@@ -27,9 +28,9 @@ import { InitOptions } from 'i18next';
import { i18n, Trans, useTranslation } from 'next-i18next';
import Image from 'next/image';
import { ReactNode } from 'react';
import { CURRENT_VERSION } from '../../../data/constants';
import { useConfigContext } from '../../config/provider';
import { useConfigStore } from '../../config/store';
import { usePackageAttributesStore } from '../../tools/client/zustands/usePackageAttributesStore';
import { usePrimaryGradient } from '../layout/useGradient';
import Credits from '../Settings/Common/Credits';
@@ -140,6 +141,7 @@ interface ExtendedInitOptions extends InitOptions {
const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => {
// TODO: Fix this to not request. Pass it as a prop.
const colorGradiant = usePrimaryGradient();
const { attributes } = usePackageAttributesStore();
const { configVersion } = useConfigContext();
const { configs } = useConfigStore();
@@ -198,7 +200,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
content: (
<Group position="right">
<Badge variant="gradient" gradient={colorGradiant}>
{CURRENT_VERSION}
{attributes.packageVersion ?? 'Unknown'}
</Badge>
{newVersionAvailable && (
<HoverCard shadow="md" position="top" withArrow>
@@ -226,13 +228,22 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
{newVersionAvailable}
</Anchor>
</b>{' '}
is available ! Current version: {CURRENT_VERSION}
is available ! Current version: {attributes.packageVersion}
</HoverCard.Dropdown>
</HoverCard>
)}
</Group>
),
},
{
icon: <IconAnchor size={20} />,
label: 'Node environment',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{attributes.environment}
</Badge>
),
},
...items,
];

View File

@@ -45,7 +45,14 @@ export const ChangePositionModal = ({
const width = parseInt(form.values.width, 10);
const height = parseInt(form.values.height, 10);
if (!form.values.x || !form.values.y || Number.isNaN(width) || Number.isNaN(height)) return;
if (
form.values.x === null ||
form.values.y === null ||
Number.isNaN(width) ||
Number.isNaN(height)
) {
return;
}
onSubmit(form.values.x, form.values.y, width, height);
};

View File

@@ -1,5 +1,6 @@
import { createStyles, Flex, Tabs, TextInput } from '@mantine/core';
import { Autocomplete, createStyles, Flex, Tabs } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
@@ -18,16 +19,21 @@ export const AppearanceTab = ({
}: AppearanceTabProps) => {
const { t } = useTranslation('layout/modals/add-app');
const { classes } = useStyles();
const { isLoading, error, data } = useQuery({
queryKey: ['autocompleteLocale'],
queryFn: () => fetch('/api/getLocalImages').then((res) => res.json()),
});
return (
<Tabs.Panel value="appearance" pt="lg">
<Flex gap={5}>
<TextInput
<Autocomplete
className={classes.textInput}
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
label={t('appearance.icon.label')}
description={t('appearance.icon.description')}
variant="default"
data={data?.files ?? []}
withAsterisk
required
{...form.getInputProps('appearance.iconUrl')}

View File

@@ -31,7 +31,7 @@ interface IconSelectorProps {
}
export const IconSelector = ({ onChange, allowAppNamePropagation, form }: IconSelectorProps) => {
const { t } = useTranslation('layout/tools');
const { t } = useTranslation('layout/modals/icon-picker');
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png',

View File

@@ -50,7 +50,7 @@ export const AvailableElementTypes = ({
{
id: uuidv4(),
// Thank you ChatGPT ;)
position: previousConfig.wrappers.length + 1,
position: previousConfig.categories.length + 1,
},
],
categories: [

View File

@@ -1,6 +1,8 @@
import { Group, Title } from '@mantine/core';
import { Accordion, Title } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { useConfigContext } from '../../../../config/provider';
import { CategoryType } from '../../../../types/category';
import { HomarrCardWrapper } from '../../Tiles/HomarrCardWrapper';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { useGridstack } from '../gridstack/use-gridstack';
import { WrapperContent } from '../WrapperContent';
@@ -13,20 +15,47 @@ interface DashboardCategoryProps {
export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
const { refs, apps, widgets } = useGridstack('category', category.id);
const isEditMode = useEditModeStore((x) => x.enabled);
const { config } = useConfigContext();
const { classes: cardClasses } = useCardStyles(true);
const categoryList = config?.categories.map((x) => x.name) ?? [];
const [toggledCategories, setToggledCategories] = useLocalStorage({
key: `${config?.configProperties.name}-app-shelf-toggled`,
// This is a bit of a hack to toggle the categories on the first load, return a string[] of the categories
defaultValue: categoryList,
});
return (
<HomarrCardWrapper pt={10} mx={10} isCategory>
<Group position="apart" align="center">
<Title order={3}>{category.name}</Title>
{isEditMode ? <CategoryEditMenu category={category} /> : null}
</Group>
<div
className="grid-stack grid-stack-category"
data-category={category.id}
ref={refs.wrapper}
>
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
</div>
</HomarrCardWrapper>
<Accordion
classNames={{
item: cardClasses.card,
}}
mx={10}
chevronPosition="left"
multiple
value={isEditMode ? categoryList : toggledCategories}
variant="separated"
radius="lg"
onChange={(state) => {
// Cancel if edit mode is on
if (isEditMode) return;
setToggledCategories([...state]);
}}
>
<Accordion.Item value={category.name}>
<Accordion.Control icon={isEditMode && <CategoryEditMenu category={category} />}>
<Title order={3}>{category.name}</Title>
</Accordion.Control>
<Accordion.Panel>
<div
className="grid-stack grid-stack-category"
data-category={category.id}
ref={refs.wrapper}
>
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
</div>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
};

View File

@@ -22,7 +22,7 @@ export const CategoryEditMenu = ({ category }: CategoryEditMenuProps) => {
useCategoryActions(configName, category);
return (
<Menu withinPortal position="left-start" withArrow>
<Menu withinPortal withArrow>
<Menu.Target>
<ActionIcon>
<IconDots />

View File

@@ -1,8 +1,10 @@
import { v4 as uuidv4 } from 'uuid';
import { useConfigStore } from '../../../../config/store';
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
import { AppType } from '../../../../types/app';
import { CategoryType } from '../../../../types/category';
import { WrapperType } from '../../../../types/wrapper';
import { IWidget } from '../../../../widgets/widgets';
import { CategoryEditModalInnerProps } from './CategoryEditModal';
export const useCategoryActions = (configName: string | undefined, category: CategoryType) => {
@@ -185,29 +187,75 @@ export const useCategoryActions = (configName: string | undefined, category: Cat
const currentItem = previous.categories.find((x) => x.id === category.id);
if (!currentItem) return previous;
// Find the main wrapper
const mainWrapper = previous.wrappers.find((x) => x.position === 1);
const mainWrapper = previous.wrappers.find((x) => x.position === 0);
const mainWrapperId = mainWrapper?.id ?? 'default';
// Check that the app has an area.type or "category" and that the area.id is the current category
const appsToMove = previous.apps.filter(
(x) => x.area && x.area.type === 'category' && x.area.properties.id === currentItem.id
);
appsToMove.forEach((x) => {
// eslint-disable-next-line no-param-reassign
x.area = { type: 'wrapper', properties: { id: mainWrapper?.id ?? 'default' } };
});
const isAppAffectedFilter = (app: AppType): boolean => {
if (!app.area) {
return false;
}
const widgetsToMove = previous.widgets.filter(
(x) => x.area && x.area.type === 'category' && x.area.properties.id === currentItem.id
);
if (app.area.type !== 'category') {
return false;
}
widgetsToMove.forEach((x) => {
// eslint-disable-next-line no-param-reassign
x.area = { type: 'wrapper', properties: { id: mainWrapper?.id ?? 'default' } };
});
if (app.area.properties.id === mainWrapperId) {
return false;
}
return app.area.properties.id === currentItem.id;
};
const isWidgetAffectedFilter = (widget: IWidget<string, any>): boolean => {
if (!widget.area) {
return false;
}
if (widget.area.type !== 'category') {
return false;
}
if (widget.area.properties.id === mainWrapperId) {
return false;
}
return widget.area.properties.id === currentItem.id;
};
return {
...previous,
apps: previous.apps,
apps: [
...previous.apps.filter((x) => !isAppAffectedFilter(x)),
...previous.apps
.filter((x) => isAppAffectedFilter(x))
.map((app): AppType => ({
...app,
area: {
...app.area,
type: 'wrapper',
properties: {
...app.area.properties,
id: mainWrapperId,
},
},
})),
],
widgets: [
...previous.widgets.filter((widget) => !isWidgetAffectedFilter(widget)),
...previous.widgets
.filter((widget) => isWidgetAffectedFilter(widget))
.map((widget): IWidget<string, any> => ({
...widget,
area: {
...widget.area,
type: 'wrapper',
properties: {
...widget.area.properties,
id: mainWrapperId,
},
},
})),
],
categories: previous.categories.filter((x) => x.id !== category.id),
wrappers: previous.wrappers.filter((x) => x.position !== currentItem.position),
};

View File

@@ -4,6 +4,7 @@ import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core';
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons';
import { getCookie } from 'cookies-next';
import { Trans, useTranslation } from 'next-i18next';
import { useHotkeys } from '@mantine/hooks';
import { hideNotification, showNotification } from '@mantine/notifications';
import { useConfigContext } from '../../../../../config/provider';
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
@@ -23,6 +24,8 @@ export const ToggleEditModeAction = () => {
const { config } = useConfigContext();
const { classes } = useCardStyles(true);
useHotkeys([['ctrl+E', toggleEditMode]]);
const toggleButtonClicked = () => {
toggleEditMode();
if (enabled || config === undefined || config?.schemaVersion === undefined) {

View File

@@ -1,10 +1,10 @@
import { Box, createStyles, Group, Header as MantineHeader, Indicator } from '@mantine/core';
import { useEffect, useState } from 'react';
import { CURRENT_VERSION, REPO_URL } from '../../../../data/constants';
import { useConfigContext } from '../../../config/provider';
import { REPO_URL } from '../../../../data/constants';
import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { usePackageAttributesStore } from '../../../tools/client/zustands/usePackageAttributesStore';
import { Logo } from '../Logo';
import { useCardStyles } from '../useCardStyles';
import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { ToggleEditModeAction } from './Actions/ToggleEditMode/ToggleEditMode';
import { Search } from './Search';
import { SettingsMenu } from './SettingsMenu';
@@ -14,23 +14,22 @@ export const HeaderHeight = 64;
export function Header(props: any) {
const { classes } = useStyles();
const { classes: cardClasses } = useCardStyles(false);
const { config } = useConfigContext();
const { attributes } = usePackageAttributesStore();
const [newVersionAvailable, setNewVersionAvailable] = useState<string>('');
useEffect(() => {
// Fetch Data here when component first mounted
fetch(`https://api.github.com/repos/${REPO_URL}/releases/latest`).then((res) => {
res.json().then((data) => {
if (data.tag_name > CURRENT_VERSION) {
if (data.tag_name > `v${attributes.packageVersion}`) {
setNewVersionAvailable(data.tag_name);
}
});
});
}, [CURRENT_VERSION]);
}, []);
return (
<MantineHeader height={HeaderHeight} className={cardClasses.card}>
<MantineHeader height="auto" className={cardClasses.card}>
<Group p="xs" noWrap grow>
<Box className={classes.hide}>
<Logo />

View File

@@ -13,12 +13,14 @@ import {
import { useDebouncedValue, useHotkeys } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/icons';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { useConfigContext } from '../../../config/provider';
import { OverseerrMediaDisplay } from '../../../modules/common';
import { IModule } from '../../../modules/ModuleTypes';
import { ConfigType } from '../../../types/config';
import { searchUrls } from '../../Settings/Common/SearchEngine/SearchEngineSelector';
import Tip from '../Tip';
import { useCardStyles } from '../useCardStyles';
@@ -54,8 +56,8 @@ export function Search() {
const { t } = useTranslation('modules/search');
const { config } = useConfigContext();
const [searchQuery, setSearchQuery] = useState('');
const [debounced, cancel] = useDebouncedValue(searchQuery, 250);
const { classes: cardClasses } = useCardStyles(false);
const [debounced] = useDebouncedValue(searchQuery, 250);
const { classes: cardClasses } = useCardStyles(true);
const isOverseerrEnabled = config?.apps.some(
(x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr'
@@ -136,30 +138,40 @@ export function Search() {
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 openTarget = getOpenTarget(config);
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 ?? []);
});
const {
data: OverseerrResults,
isLoading,
error,
} = useQuery(
['overseerr', debounced],
async () => {
if (debounced !== '' && selectedSearchEngine.value === 'overseerr' && debounced.length > 3) {
const res = await axios.get(`/api/modules/overseerr?query=${debounced}`);
return res.data.results ?? [];
}
return [];
},
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchInterval: false,
}
}, [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}
opened={OverseerrResults && OverseerrResults.length > 0 && opened && searchQuery.length > 3}
position="bottom"
withinPortal
shadow="md"
@@ -182,7 +194,7 @@ export function Search() {
setOpened(false);
if (item.url) {
setSearchQuery('');
window.open(item.openedUrl ? item.openedUrl : item.url, openInNewTab);
window.open(item.openedUrl ? item.openedUrl : item.url, openTarget);
}
}}
// Replace %s if it is in selectedSearchEngine.url with searchQuery, otherwise append searchQuery at the end of it
@@ -193,9 +205,9 @@ export function Search() {
autocompleteData.length === 0
) {
if (selectedSearchEngine.url.includes('%s')) {
window.open(selectedSearchEngine.url.replace('%s', searchQuery), openInNewTab);
window.open(selectedSearchEngine.url.replace('%s', searchQuery), openTarget);
} else {
window.open(selectedSearchEngine.url + searchQuery, openInNewTab);
window.open(selectedSearchEngine.url + searchQuery, openTarget);
}
}
}}
@@ -207,16 +219,17 @@ export function Search() {
/>
</Popover.Target>
<Popover.Dropdown>
<div>
<ScrollArea style={{ height: 400, width: 420 }} offsetScrollbars>
{OverseerrResults.slice(0, 5).map((result, index) => (
<ScrollArea style={{ height: '80vh', maxWidth: '90vw' }} offsetScrollbars>
{OverseerrResults &&
OverseerrResults.slice(0, 4).map((result: any, index: number) => (
<React.Fragment key={index}>
<OverseerrMediaDisplay key={result.id} media={result} />
{index < OverseerrResults.length - 1 && <Divider variant="dashed" my="xl" />}
{index < OverseerrResults.length - 1 && index < 3 && (
<Divider variant="dashed" my="xs" />
)}
</React.Fragment>
))}
</ScrollArea>
</div>
</ScrollArea>
</Popover.Dropdown>
</Popover>
</Box>
@@ -287,3 +300,11 @@ export function Search() {
});
}
}
const getOpenTarget = (config: ConfigType | undefined): '_blank' | '_self' => {
if (!config || config.settings.common.searchEngine.properties.openInNewTab === undefined) {
return '_blank';
}
return config.settings.common.searchEngine.properties.openInNewTab ? '_blank' : '_self';
};