Bookmark widget (#890)

* 🚧 Bookmark widget

*  Add input type

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>

*  Add content display and input fields

* 🐛 Fix delete button updating to invalid schema

* 🌐 Add translations for options

*  Add field for image

* ♻️ Refactor IconSelector and add forward ref

* 🦺 Add form validation

* 🦺 Add validation for icon url and fix state for icon picker

* 🌐 PR feedback

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2023-05-15 09:54:50 +02:00
committed by GitHub
parent 194da2b6e5
commit c52acd2913
14 changed files with 708 additions and 210 deletions

View File

@@ -15,13 +15,13 @@ import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { AppType } from '../../../../types/app';
import { DebouncedImage } from '../../../IconSelector/DebouncedImage';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
import { DebouncedAppIcon } from './Tabs/Shared/DebouncedAppIcon';
import { EditAppModalTab } from './Tabs/type';
const appUrlRegex =
@@ -138,7 +138,7 @@ export const EditAppModal = ({
</Alert>
))}
<Stack spacing={0} align="center" my="lg">
<DebouncedAppIcon form={form} width={120} height={120} />
<DebouncedImage src={form.values.appearance.iconUrl} width={120} height={120} />
<Text align="center" weight="bold" size="lg" mt="md">
{form.values.name ?? 'New App'}

View File

@@ -1,10 +1,9 @@
import { Flex, Tabs } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { useEffect } from 'react';
import { useGetDashboardIcons } from '../../../../../../hooks/icons/useGetDashboardIcons';
import { useEffect, useRef } from 'react';
import { AppType } from '../../../../../../types/app';
import { IconSelector } from './IconSelector';
import { IconSelector } from '../../../../../IconSelector/IconSelector';
interface AppearanceTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
@@ -17,8 +16,7 @@ export const AppearanceTab = ({
disallowAppNameProgagation,
allowAppNamePropagation,
}: AppearanceTabProps) => {
const { data, isLoading } = useGetDashboardIcons();
const iconSelectorRef = useRef();
const [debouncedValue] = useDebouncedValue(form.values.name, 500);
useEffect(() => {
@@ -26,26 +24,28 @@ export const AppearanceTab = ({
return;
}
const matchingDebouncedIcon = data
?.flatMap((x) => x.entries)
.find((x) => replaceCharacters(x.name.split('.')[0]) === replaceCharacters(debouncedValue));
if (!matchingDebouncedIcon) {
if (!iconSelectorRef.current) {
return;
}
form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url);
const currentRef = iconSelectorRef.current as {
chooseFirstOrDefault: (debouncedValue: string) => void;
};
currentRef.chooseFirstOrDefault(debouncedValue);
}, [debouncedValue]);
return (
<Tabs.Panel value="appearance" pt="lg">
<Flex gap={5}>
<IconSelector
form={form}
data={data}
isLoading={isLoading}
allowAppNamePropagation={allowAppNamePropagation}
disallowAppNameProgagation={disallowAppNameProgagation}
defaultValue={form.values.appearance.iconUrl}
onChange={(value) => {
form.setFieldValue('appearance.iconUrl', value);
disallowAppNameProgagation();
}}
value={form.values.appearance.iconUrl}
ref={iconSelectorRef}
/>
</Flex>
</Tabs.Panel>

View File

@@ -1,164 +0,0 @@
import {
Autocomplete,
Box,
CloseButton,
createStyles,
Group,
Image,
Loader,
ScrollArea,
SelectItemProps,
Stack,
Text,
Title,
} from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconSearch } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { forwardRef } from 'react';
import { humanFileSize } from '../../../../../../tools/humanFileSize';
import { NormalizedIconRepositoryResult } from '../../../../../../tools/server/images/abstract-icons-repository';
import { AppType } from '../../../../../../types/app';
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
interface IconSelectorProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
data: NormalizedIconRepositoryResult[] | undefined;
isLoading: boolean;
disallowAppNameProgagation: () => void;
allowAppNamePropagation: boolean;
}
export const IconSelector = ({
form,
data,
isLoading,
allowAppNamePropagation,
disallowAppNameProgagation,
}: IconSelectorProps) => {
const { t } = useTranslation('layout/modals/add-app');
const { classes } = useStyles();
const a =
data === undefined
? []
: data.flatMap((repository) =>
repository.entries.map((entry) => ({
url: entry.url,
label: entry.name,
size: entry.size,
value: entry.url,
group: repository.name,
copyright: repository.copyright,
}))
);
return (
<Stack w="100%">
<Autocomplete
nothingFound={
<Stack align="center" spacing="xs" my="lg">
<IconSearch />
<Title order={6} align="center">
{t('appearance.icon.autocomplete.title')}
</Title>
<Text align="center" maw={350}>
{t('appearance.icon.autocomplete.text')}
</Text>
</Stack>
}
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
rightSection={
form.values.appearance.iconUrl.length > 0 ? (
<CloseButton onClick={() => form.setFieldValue('appearance.iconUrl', '')} />
) : null
}
itemComponent={AutoCompleteItem}
className={classes.textInput}
data={a}
limit={25}
label={t('appearance.icon.label')}
description={t('appearance.icon.description', {
suggestionsCount: data?.reduce((a, b) => a + b.count, 0) ?? 0,
})}
filter={(search, item) =>
item.value
.toLowerCase()
.replaceAll('_', '')
.replaceAll(' ', '-')
.includes(search.toLowerCase().replaceAll('_', '').replaceAll(' ', '-'))
}
variant="default"
withAsterisk
dropdownComponent={(props: any) => <ScrollArea {...props} mah={250} />}
dropdownPosition="bottom"
required
onChange={(event) => {
if (allowAppNamePropagation) {
disallowAppNameProgagation();
}
form.setFieldValue('appearance.iconUrl', event);
}}
value={form.values.appearance.iconUrl}
/>
{(!data || isLoading) && (
<Group>
<Loader variant="oval" size="sm" />
<Stack spacing={0}>
<Text size="xs" weight="bold">
{t('appearance.icon.noItems.title')}
</Text>
<Text color="dimmed" size="xs">
{t('appearance.icon.noItems.text')}
</Text>
</Stack>
</Group>
)}
</Stack>
);
};
const useStyles = createStyles(() => ({
textInput: {
flexGrow: 1,
},
}));
const AutoCompleteItem = forwardRef<HTMLDivElement, ItemProps>(
({ label, size, copyright, url, ...others }: ItemProps, ref) => (
<div ref={ref} {...others}>
<Group noWrap>
<Box
sx={(theme) => ({
backgroundColor:
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2],
borderRadius: theme.radius.md,
})}
p={2}
>
<Image src={url} width={30} height={30} fit="contain" />
</Box>
<Stack spacing={0}>
<Text>{label}</Text>
<Group>
<Text color="dimmed" size="xs">
{humanFileSize(size, false)}
</Text>
{copyright && (
<Text color="dimmed" size="xs">
© {copyright}
</Text>
)}
</Group>
</Stack>
</Group>
</div>
)
);
interface ItemProps extends SelectItemProps {
url: string;
group: string;
size: number;
copyright: string | undefined;
}

View File

@@ -1,59 +0,0 @@
// disabled due to too many dynamic targets for next image cache
/* eslint-disable @next/next/no-img-element */
import Image from 'next/image';
import { createStyles, Loader } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { AppType } from '../../../../../../types/app';
interface DebouncedAppIconProps {
width: number;
height: number;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
debouncedWaitPeriod?: number;
}
export const DebouncedAppIcon = ({
form,
width,
height,
debouncedWaitPeriod = 1000,
}: DebouncedAppIconProps) => {
const { classes } = useStyles();
const [debouncedIconImageUrl] = useDebouncedValue(
form.values.appearance.iconUrl,
debouncedWaitPeriod
);
if (debouncedIconImageUrl !== form.values.appearance.iconUrl) {
return <Loader width={width} height={height} />;
}
if (debouncedIconImageUrl.length > 0) {
return (
<img
className={classes.iconImage}
src={debouncedIconImageUrl}
width={width}
height={height}
alt=""
/>
);
}
return (
<Image
className={classes.iconImage}
src="/imgs/logo/logo.png"
width={width}
height={height}
alt=""
/>
);
};
const useStyles = createStyles(() => ({
iconImage: {
objectFit: 'contain',
},
}));