feat: add [homarr_base] replacement for external urls (#2024)
This commit is contained in:
@@ -106,6 +106,7 @@
|
|||||||
"rss-parser": "^3.12.0",
|
"rss-parser": "^3.12.0",
|
||||||
"sabnzbd-api": "^1.5.0",
|
"sabnzbd-api": "^1.5.0",
|
||||||
"swagger-ui-react": "^5.11.0",
|
"swagger-ui-react": "^5.11.0",
|
||||||
|
"tldts": "^6.1.18",
|
||||||
"trpc-openapi": "^1.2.0",
|
"trpc-openapi": "^1.2.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"xml-js": "^1.6.11",
|
"xml-js": "^1.6.11",
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
},
|
},
|
||||||
"externalAddress": {
|
"externalAddress": {
|
||||||
"label": "External address",
|
"label": "External address",
|
||||||
"description": "URL that will be opened when clicking on the app."
|
"description": "URL that will be opened when clicking on the app.",
|
||||||
|
"tooltip": "You can use a few variables to create dynamic addresses:<br><br><b>[homarr_base]</b> : full address excluding port and path. <i>(Example: 'https://subdomain.homarr.dev')</i><br><b>[homarr_hostname]</b> : full base url including it's current subdomain. <i>(Example: 'subdomain.homarr.dev')</i><br><b>[homarr_domain]</b> : domain with subdomain filtered out. <i>(Example: `homarr.dev')</i><br><b>[homarr_protocol]</b> : <i>http/https</i><br><br>These variables all depend on the current url."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"behaviour": {
|
"behaviour": {
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
"label": "Open in new tab",
|
"label": "Open in new tab",
|
||||||
"description": "Open the app in a new tab instead of the current one."
|
"description": "Open the app in a new tab instead of the current one."
|
||||||
},
|
},
|
||||||
"tooltipDescription":{
|
"tooltipDescription": {
|
||||||
"label": "Application Description",
|
"label": "Application Description",
|
||||||
"description": "The text you enter will appear when hovering over your app.\r\nUse this to give users more details about your app or leave empty to have nothing."
|
"description": "The text you enter will appear when hovering over your app.\r\nUse this to give users more details about your app or leave empty to have nothing."
|
||||||
},
|
},
|
||||||
@@ -68,32 +69,32 @@
|
|||||||
"text": "This may take a few seconds"
|
"text": "This may take a few seconds"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"appNameFontSize":{
|
"appNameFontSize": {
|
||||||
"label":"App Name Font Size",
|
"label": "App Name Font Size",
|
||||||
"description":"Set the font size for when the app name is shown on the tile."
|
"description": "Set the font size for when the app name is shown on the tile."
|
||||||
},
|
},
|
||||||
"appNameStatus":{
|
"appNameStatus": {
|
||||||
"label":"App Name Status",
|
"label": "App Name Status",
|
||||||
"description":"Choose where you want the title to show up, if at all.",
|
"description": "Choose where you want the title to show up, if at all.",
|
||||||
"dropdown": {
|
"dropdown": {
|
||||||
"normal":"Show title on tile only",
|
"normal": "Show title on tile only",
|
||||||
"hover":"Show title on tooltip hover only",
|
"hover": "Show title on tooltip hover only",
|
||||||
"hidden":"Don't show at all"
|
"hidden": "Don't show at all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"positionAppName":{
|
"positionAppName": {
|
||||||
"label":"App Name Position",
|
"label": "App Name Position",
|
||||||
"description":"Position of the app's name relative to the icon.",
|
"description": "Position of the app's name relative to the icon.",
|
||||||
"dropdown": {
|
"dropdown": {
|
||||||
"top":"Top",
|
"top": "Top",
|
||||||
"right":"Right",
|
"right": "Right",
|
||||||
"bottom":"Bottom",
|
"bottom": "Bottom",
|
||||||
"left":"Left"
|
"left": "Left"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lineClampAppName":{
|
"lineClampAppName": {
|
||||||
"label":"App Name Line Clamp",
|
"label": "App Name Line Clamp",
|
||||||
"description":"Defines on how many lines your title should fit at it's maximum. Set 0 for unlimited."
|
"description": "Defines on how many lines your title should fit at it's maximum. Set 0 for unlimited."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"integration": {
|
"integration": {
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ export const EditAppModal = ({
|
|||||||
return t('validation.noExternalUri');
|
return t('validation.noExternalUri');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!url.match(appUrlWithAnyProtocolRegex)) {
|
if (
|
||||||
|
!url.match(appUrlWithAnyProtocolRegex) &&
|
||||||
|
!url.startsWith('[homarr_base]') &&
|
||||||
|
!url.startsWith('[homarr_protocol]://')
|
||||||
|
) {
|
||||||
return t('validation.invalidExternalUri');
|
return t('validation.invalidExternalUri');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +114,7 @@ export const EditAppModal = ({
|
|||||||
|
|
||||||
// also close the parent modal
|
// also close the parent modal
|
||||||
context.closeAll();
|
context.closeAll();
|
||||||
umami.track('Add app', { name: values.name });
|
umami.track('Add app', { name: values.name });
|
||||||
};
|
};
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<EditAppModalTab>('general');
|
const [activeTab, setActiveTab] = useState<EditAppModalTab>('general');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { UseFormReturnType } from '@mantine/form';
|
|||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconClick, IconCursorText, IconLink } from '@tabler/icons-react';
|
import { IconClick, IconCursorText, IconLink } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
import { InfoCard } from '~/components/InfoCard/InfoCard';
|
||||||
import { AppType } from '~/types/app';
|
import { AppType } from '~/types/app';
|
||||||
|
|
||||||
import { EditAppModalTab } from '../type';
|
import { EditAppModalTab } from '../type';
|
||||||
@@ -50,14 +51,21 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
|
|||||||
form.setFieldValue('url', e.target.value);
|
form.setFieldValue('url', e.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<Stack style={{ gap: 0 }}>
|
||||||
icon={<IconClick size={16} />}
|
<Group style={{ gap: '0.25rem' }}>
|
||||||
label={t('general.externalAddress.label')}
|
<Text size="0.875rem" weight={500}>
|
||||||
description={t('general.externalAddress.description')}
|
{t('general.externalAddress.label')}
|
||||||
placeholder="https://homarr.mywebsite.com/"
|
</Text>
|
||||||
variant="default"
|
<InfoCard message={t('general.externalAddress.tooltip')} />
|
||||||
{...form.getInputProps('behaviour.externalUrl')}
|
</Group>
|
||||||
/>
|
<TextInput
|
||||||
|
icon={<IconClick size={16} />}
|
||||||
|
description={t('general.externalAddress.description')}
|
||||||
|
placeholder="https://homarr.mywebsite.com/"
|
||||||
|
variant="default"
|
||||||
|
{...form.getInputProps('behaviour.externalUrl')}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Collapse in={opened}>
|
<Collapse in={opened}>
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
@@ -81,7 +89,9 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
|
|||||||
</Collapse>
|
</Collapse>
|
||||||
|
|
||||||
{!form.values.behaviour.externalUrl.startsWith('https://') &&
|
{!form.values.behaviour.externalUrl.startsWith('https://') &&
|
||||||
!form.values.behaviour.externalUrl.startsWith('http://') && (
|
!form.values.behaviour.externalUrl.startsWith('http://') &&
|
||||||
|
!form.values.behaviour.externalUrl.startsWith('[homarr_base]') &&
|
||||||
|
!form.values.behaviour.externalUrl.startsWith('[homarr_protocol]://') && (
|
||||||
<Text color="red" mt="sm" size="sm">
|
<Text color="red" mt="sm" size="sm">
|
||||||
{t('behaviour.customProtocolWarning')}
|
{t('behaviour.customProtocolWarning')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Affix, Box, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
import { Box, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
||||||
import { createStyles, useMantineTheme } from '@mantine/styles';
|
import { createStyles, useMantineTheme } from '@mantine/styles';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import { useExternalUrl } from '~/hooks/useExternalUrl';
|
||||||
import { AppType } from '~/types/app';
|
import { AppType } from '~/types/app';
|
||||||
|
|
||||||
import { useEditModeStore } from '../../Views/useEditModeStore';
|
import { useEditModeStore } from '../../Views/useEditModeStore';
|
||||||
@@ -26,6 +26,7 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
|||||||
.join(': ');
|
.join(': ');
|
||||||
|
|
||||||
const isRow = app.appearance.positionAppName.includes('row');
|
const isRow = app.appearance.positionAppName.includes('row');
|
||||||
|
const href = useExternalUrl(app);
|
||||||
|
|
||||||
function Inner() {
|
function Inner() {
|
||||||
return (
|
return (
|
||||||
@@ -88,7 +89,7 @@ export const AppTile = ({ className, app }: AppTileProps) => {
|
|||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
|
||||||
component="a"
|
component="a"
|
||||||
href={app.behaviour.externalUrl.length > 0 ? app.behaviour.externalUrl : app.url}
|
href={href}
|
||||||
target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'}
|
target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'}
|
||||||
className={`${classes.button} ${classes.base}`}
|
className={`${classes.button} ${classes.base}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { modals } from '@mantine/modals';
|
|||||||
import { IconDotsVertical, IconShare3 } from '@tabler/icons-react';
|
import { IconDotsVertical, IconShare3 } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
import { useConfigContext } from '~/config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
import { useGetExternalUrl } from '~/hooks/useExternalUrl';
|
||||||
import { CategoryType } from '~/types/category';
|
import { CategoryType } from '~/types/category';
|
||||||
|
|
||||||
import { useCardStyles } from '../../../layout/Common/useCardStyles';
|
import { useCardStyles } from '../../../layout/Common/useCardStyles';
|
||||||
@@ -33,6 +34,7 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
|||||||
const { classes: cardClasses, cx } = useCardStyles(true);
|
const { classes: cardClasses, cx } = useCardStyles(true);
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
const { t } = useTranslation(['layout/common', 'common']);
|
const { t } = useTranslation(['layout/common', 'common']);
|
||||||
|
const getAppUrl = useGetExternalUrl();
|
||||||
|
|
||||||
const categoryList = config?.categories.map((x) => x.name) ?? [];
|
const categoryList = config?.categories.map((x) => x.name) ?? [];
|
||||||
const [toggledCategories, setToggledCategories] = useLocalStorage({
|
const [toggledCategories, setToggledCategories] = useLocalStorage({
|
||||||
@@ -44,7 +46,8 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
|||||||
const handleMenuClick = () => {
|
const handleMenuClick = () => {
|
||||||
for (let i = 0; i < apps.length; i += 1) {
|
for (let i = 0; i < apps.length; i += 1) {
|
||||||
const app = apps[i];
|
const app = apps[i];
|
||||||
const popUp = window.open(app.url, app.id);
|
const appUrl = getAppUrl(app);
|
||||||
|
const popUp = window.open(appUrl, app.id);
|
||||||
|
|
||||||
if (popUp === null) {
|
if (popUp === null) {
|
||||||
modals.openConfirmModal({
|
modals.openConfirmModal({
|
||||||
@@ -114,7 +117,9 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
) : <CategoryEditMenu category={category} />}
|
) : (
|
||||||
|
<CategoryEditMenu category={category} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<div
|
<div
|
||||||
|
|||||||
39
src/hooks/useExternalUrl.ts
Normal file
39
src/hooks/useExternalUrl.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import * as tldts from 'tldts';
|
||||||
|
import { AppType } from '~/types/app';
|
||||||
|
|
||||||
|
export const useGetExternalUrl = () => {
|
||||||
|
const parsedUrl = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return tldts.parse(window.location.toString());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [window.location]);
|
||||||
|
|
||||||
|
const getHref = useCallback(
|
||||||
|
(appType: AppType) => {
|
||||||
|
if (appType.behaviour.externalUrl.length > 0) {
|
||||||
|
return appType.behaviour.externalUrl
|
||||||
|
.replace('[homarr_base]', `${window.location.protocol}//${window.location.hostname}`)
|
||||||
|
.replace('[homarr_hostname]', parsedUrl?.hostname ?? '')
|
||||||
|
.replace('[homarr_domain]', parsedUrl?.domain ?? '')
|
||||||
|
.replace('[homarr_protocol]', window.location.protocol.replace(':', ''));
|
||||||
|
}
|
||||||
|
return appType.url;
|
||||||
|
},
|
||||||
|
[parsedUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getHref;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useExternalUrl = (app: AppType) => {
|
||||||
|
const getHref = useGetExternalUrl();
|
||||||
|
|
||||||
|
const href = useMemo(() => {
|
||||||
|
return getHref(app);
|
||||||
|
}, [app, getHref]);
|
||||||
|
|
||||||
|
return href;
|
||||||
|
};
|
||||||
19
yarn.lock
19
yarn.lock
@@ -7495,6 +7495,7 @@ __metadata:
|
|||||||
sabnzbd-api: ^1.5.0
|
sabnzbd-api: ^1.5.0
|
||||||
sass: ^1.56.1
|
sass: ^1.56.1
|
||||||
swagger-ui-react: ^5.11.0
|
swagger-ui-react: ^5.11.0
|
||||||
|
tldts: ^6.1.18
|
||||||
trpc-openapi: ^1.2.0
|
trpc-openapi: ^1.2.0
|
||||||
ts-node: latest
|
ts-node: latest
|
||||||
turbo: ^1.10.12
|
turbo: ^1.10.12
|
||||||
@@ -11885,6 +11886,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"tldts-core@npm:^6.1.18":
|
||||||
|
version: 6.1.18
|
||||||
|
resolution: "tldts-core@npm:6.1.18"
|
||||||
|
checksum: 392d490c04aca5b40f16666fb6cbc9d7ef6df42884b1ac196736ffdeeb378019962dbc04ee00a2a85ce61944a0692c1f1cd8b64896277949906991146830314f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"tldts@npm:^6.1.18":
|
||||||
|
version: 6.1.18
|
||||||
|
resolution: "tldts@npm:6.1.18"
|
||||||
|
dependencies:
|
||||||
|
tldts-core: ^6.1.18
|
||||||
|
bin:
|
||||||
|
tldts: bin/cli.js
|
||||||
|
checksum: 04ec7d6a5ad42ddedd9dd250d9cde37608b09bf28eecb94bad8c49d65225d861e0bddc6e1cea3253baf8fc96722e0d2fc1e926eb73b7a35396c77346efaf70f0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"tmp@npm:^0.0.33":
|
"tmp@npm:^0.0.33":
|
||||||
version: 0.0.33
|
version: 0.0.33
|
||||||
resolution: "tmp@npm:0.0.33"
|
resolution: "tmp@npm:0.0.33"
|
||||||
|
|||||||
Reference in New Issue
Block a user