mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 17:26:26 +01:00
🔀 Merge branch 'dev' into next-13
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -50,7 +50,7 @@ export const AvailableElementTypes = ({
|
||||
{
|
||||
id: uuidv4(),
|
||||
// Thank you ChatGPT ;)
|
||||
position: previousConfig.wrappers.length + 1,
|
||||
position: previousConfig.categories.length + 1,
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user