Starting guac clients

This commit is contained in:
Sean Edmunds
2025-12-23 14:55:47 -06:00
parent 063a936cf7
commit 64605a341c
23 changed files with 853 additions and 480 deletions

717
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,36 +10,36 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@gfazioli/mantine-text-animate": "^2.3.4", "@gfazioli/mantine-text-animate": "^2.3.9",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@mantine/core": "^8.3.7", "@mantine/core": "^8.3.10",
"@mantine/dates": "^8.3.7", "@mantine/dates": "^8.3.10",
"@mantine/form": "^8.3.7", "@mantine/form": "^8.3.10",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^8.3.10",
"@mantine/notifications": "^8.3.7", "@mantine/notifications": "^8.3.10",
"@tanstack/react-query": "^5.90.9", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-query-persist-client": "^5.90.11", "@tanstack/react-query-persist-client": "^5.90.14",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"jotai": "^2.15.1", "jotai": "^2.16.0",
"react": "^19.2.0", "react": "^19.2.3",
"react-dom": "^19.2.0", "react-dom": "^19.2.3",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^7.9.6", "react-router-dom": "^7.11.0",
"recharts": "^3.4.1" "recharts": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.1", "@types/node": "^25.0.3",
"@types/react": "^19.2.5", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.9", "@vitest/coverage-v8": "^4.0.16",
"@vitest/ui": "^4.0.9", "@vitest/ui": "^4.0.16",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.2.2", "vite": "^7.3.0",
"vite-plugin-dts": "~4.5.4", "vite-plugin-dts": "~4.5.4",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.9" "vitest": "^4.0.16"
} }
} }

View File

@@ -1,7 +1,5 @@
import { ActionIcon, TextInput } from '@mantine/core'; import { TextInput, type TextInputProps } from '@mantine/core';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FiX } from 'react-icons/fi';
import { textInputClasses } from 'styles';
export function DebouncedInput({ export function DebouncedInput({
value: initialValue, value: initialValue,
@@ -12,7 +10,7 @@ export function DebouncedInput({
value: string | number; value: string | number;
onChange: (value: string | number) => void; onChange: (value: string | number) => void;
debounce?: number; debounce?: number;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>) { } & Omit<TextInputProps, 'onChange'>) {
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
useEffect(() => { useEffect(() => {
@@ -25,43 +23,14 @@ export function DebouncedInput({
}, debounce); }, debounce);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [value, debounce, onChange]); }, [value]);
return ( return (
<TextInput <TextInput
{...props} {...props}
value={value} value={value}
size='sm'
p={0}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
classNames={textInputClasses}
styles={{
input: {
borderRadius: 0,
borderStyle: 'none',
color: 'var(--mantine-color-primary-0)',
'&::placeholder': {
color: 'red !important',
},
},
wrapper: {
'&::placeholder': {
color: 'red !important',
},
},
}}
rightSection={
value && (
<ActionIcon
onClick={() => {
setValue('');
}}
>
<FiX />
</ActionIcon>
)
}
/> />
); );
} }

View File

@@ -7,7 +7,6 @@ import {
Stack, Stack,
Switch, Switch,
Text, Text,
TextInput,
} from '@mantine/core'; } from '@mantine/core';
import { useCommonHooks } from 'hooks'; import { useCommonHooks } from 'hooks';
import { useState } from 'react'; import { useState } from 'react';
@@ -20,6 +19,7 @@ import {
textClasses, textClasses,
textInputClasses, textInputClasses,
} from 'styles'; } from 'styles';
import { DebouncedInput } from './DebouncedInput';
export function SettingsHeader({ export function SettingsHeader({
setDragging, setDragging,
@@ -40,8 +40,8 @@ export function SettingsHeader({
highlightList: string[]; highlightList: string[];
description: string; description: string;
}; };
searchValue: string | undefined; searchValue: string | number;
setSearchValue: (searchValue: string) => void; setSearchValue: (searchValue: string | number) => void;
searchPlaceholder: string | undefined; searchPlaceholder: string | undefined;
isLoading: boolean; isLoading: boolean;
setIsLoading: (isLoading: boolean) => void; setIsLoading: (isLoading: boolean) => void;
@@ -120,7 +120,7 @@ export function SettingsHeader({
)} )}
</Group> </Group>
{searchValue !== undefined && ( {searchValue !== undefined && (
<TextInput <DebouncedInput
disabled={disabled} disabled={disabled}
w={isMobile ? undefined : '35%'} w={isMobile ? undefined : '35%'}
pos={isMobile ? undefined : 'absolute'} pos={isMobile ? undefined : 'absolute'}
@@ -129,9 +129,9 @@ export function SettingsHeader({
autoFocus={!isMobile} autoFocus={!isMobile}
placeholder={searchPlaceholder ?? 'Search...'} placeholder={searchPlaceholder ?? 'Search...'}
value={searchValue} value={searchValue}
onInput={(event: any) => { onInput={() => setIsLoading(true)}
setIsLoading(true); onChange={(value: any) => {
setSearchValue(event.target.value); setSearchValue(value);
}} }}
style={{ style={{
zIndex: 100, zIndex: 100,

View File

@@ -69,7 +69,7 @@ export function useCommonHooks() {
key: 'providerIcon', key: 'providerIcon',
defaultValue: 'FaGoogle', defaultValue: 'FaGoogle',
}); });
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [providerLabel, setProviderLabel] = useState(defaultProviderLabel); const [providerLabel, setProviderLabel] = useState(defaultProviderLabel);
const [providerURL, setProviderURL] = useState(defaultProviderURL); const [providerURL, setProviderURL] = useState(defaultProviderURL);
const [providerPrefix, setProviderPrefix] = useState(defaultProviderPrefix); const [providerPrefix, setProviderPrefix] = useState(defaultProviderPrefix);

View File

@@ -20,6 +20,7 @@ import type {
Bookmark, Bookmark,
CurrentWeather, CurrentWeather,
ExtendedForecast, ExtendedForecast,
GuacamoleClient,
Provider, Provider,
SearchHistoryItem, SearchHistoryItem,
} from 'types'; } from 'types';
@@ -48,9 +49,14 @@ export default function Search({
endpoint: 'api/providers', endpoint: 'api/providers',
}); });
const [providers, setProviders] = useState<Provider[]>([]); const [providers, setProviders] = useState<Provider[]>([]);
const [guacamoleClients, setGuacamoleClients] = useState<GuacamoleClient[]>([]);
const { data: applicationData, isFetching: isApplicationDataFetching } = useQueryAPI({ const { data: applicationData, isFetching: isApplicationDataFetching } = useQueryAPI({
endpoint: 'api/enabledApplications', endpoint: 'api/enabledApplications',
}); });
const { data: guacamoleClientData, isFetched: guacamoleClientDataIsFetched } =
useQueryAPI({
endpoint: 'api/guacamoleClients',
});
const { data: bookmarkData, isFetched: bookmarksIsFetched } = useQueryAPI({ const { data: bookmarkData, isFetched: bookmarksIsFetched } = useQueryAPI({
endpoint: 'api/bookmarks', endpoint: 'api/bookmarks',
}); });
@@ -130,10 +136,6 @@ export default function Search({
item.target.value.substr(1, item.target.value.indexOf(' ') - 1) ?? item.target.value.substr(1, item.target.value.indexOf(' ') - 1) ??
item.target.value.substr(1); item.target.value.substr(1);
subtext = item.target.value.substr(2 + option.length); subtext = item.target.value.substr(2 + option.length);
} else if (item.target.value.startsWith('!')) {
if (item.target.value.startsWith('!w')) {
subtext = item.target.value.substr(2);
}
} else { } else {
subtext = item.target.value; subtext = item.target.value;
} }
@@ -162,6 +164,13 @@ export default function Search({
? url ? url
: `http://${url}`; : `http://${url}`;
open(validUrl, '_self'); open(validUrl, '_self');
} else if (providerLabel.startsWith('Connect to')) {
const id = guacamoleClients.find(
(client: GuacamoleClient) =>
client.clientName.substring(1).toLowerCase() === item.target.value.substr(1),
)?.id;
open(`https://sean.bet/guacamole/#/${id}`, '_self');
} else if (urlPattern.test(item.target.value)) { } else if (urlPattern.test(item.target.value)) {
if (/^(https?:\/\/).*/i.test(item.target.value)) if (/^(https?:\/\/).*/i.test(item.target.value))
globalThis.location = item.target.value; globalThis.location = item.target.value;
@@ -250,7 +259,6 @@ export default function Search({
} }
} }
}; };
const onInput = (item: any) => { const onInput = (item: any) => {
openDropdown(); openDropdown();
setSearchValue(item.currentTarget.value); setSearchValue(item.currentTarget.value);
@@ -288,6 +296,17 @@ export default function Search({
} else { } else {
setProviderLabel('Invalid function'); setProviderLabel('Invalid function');
} }
} else if (item.target.value.startsWith('>')) {
if (
guacamoleClients?.find(
(client: GuacamoleClient) =>
client.clientName.substring(1).toLowerCase() === item.target.value.substr(1),
)
) {
setProviderLabel(`Connect to ${item.target.value.substr(1)}`);
} else {
setProviderLabel('Invalid connection');
}
} else { } else {
setProviderLabel(defaultProviderLabel); setProviderLabel(defaultProviderLabel);
setProviderURL(defaultProviderURL); setProviderURL(defaultProviderURL);
@@ -311,6 +330,21 @@ export default function Search({
}; };
}); });
} }
if (splittedSearch[0].startsWith('>')) {
return guacamoleClientData
.filter((guacamoleClient: GuacamoleClient) =>
guacamoleClient.clientName
.toLowerCase()
.includes(splittedSearch[0].substring(1).toLowerCase()),
)
.map((guacamoleClient: GuacamoleClient) => {
return {
label: guacamoleClient.clientName,
value: guacamoleClient.clientIdentifier,
...guacamoleClient,
};
});
}
const historyOptions = options const historyOptions = options
.filter((option: any) => { .filter((option: any) => {
return splittedSearch.every((searchWord) => return splittedSearch.every((searchWord) =>
@@ -350,7 +384,9 @@ export default function Search({
: (providers.find((p: Provider) => p.prefix === item.option.prefix)?.icon ?? : (providers.find((p: Provider) => p.prefix === item.option.prefix)?.icon ??
'') '')
]; ];
return searchValue.startsWith('!') ? ( if ((searchValue as string).startsWith('>')) {
}
return (searchValue as string).startsWith('!') ? (
<Group <Group
pos={'relative'} pos={'relative'}
m={0} m={0}
@@ -380,6 +416,26 @@ export default function Search({
{item.option.name} {item.option.name}
</Text> </Text>
</Group> </Group>
) : (searchValue as string).startsWith('>') ? (
<Group
pos={'relative'}
m={0}
p={0}
gap={0}
justify='center'
w={'100%'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Text pos={'absolute'} left={'1rem'} c={'var(--mantine-color-accent-0)'}>
Connect to
</Text>
<Text pos={'absolute'} right={'1rem'} c={'var(--mantine-color-accent-0)'}>
{item.option.name || item.option.clientName || 'Invalid connection'}
</Text>
</Group>
) : item.option.value === 'load' ? ( ) : item.option.value === 'load' ? (
<Group <Group
pos={'relative'} pos={'relative'}
@@ -419,6 +475,12 @@ export default function Search({
} }
}, [providerData, providerDataIsFetched]); }, [providerData, providerDataIsFetched]);
useEffect(() => {
if (guacamoleClientData && guacamoleClientDataIsFetched) {
setGuacamoleClients(guacamoleClientData);
}
}, [guacamoleClientData, guacamoleClientDataIsFetched]);
useEffect(() => { useEffect(() => {
if (applicationData && !isApplicationDataFetching) { if (applicationData && !isApplicationDataFetching) {
setApplications(applicationData); setApplications(applicationData);
@@ -429,7 +491,7 @@ export default function Search({
if (applications) { if (applications) {
setFilteredApplications( setFilteredApplications(
applications.filter((app: Application) => applications.filter((app: Application) =>
app.name.toLowerCase().includes(searchValue.toLowerCase()), app.name.toLowerCase().includes((searchValue as string).toLowerCase()),
), ),
); );
} }
@@ -441,7 +503,7 @@ export default function Search({
bookmarkData.filter( bookmarkData.filter(
(bookmark: Bookmark) => (bookmark: Bookmark) =>
bookmark.enabled === 1 && bookmark.enabled === 1 &&
bookmark.name.toLowerCase().includes(searchValue.toLowerCase()), bookmark.name.toLowerCase().includes((searchValue as string).toLowerCase()),
), ),
); );
} }
@@ -467,7 +529,7 @@ export default function Search({
}, [listIndex, dropdownOpened]); }, [listIndex, dropdownOpened]);
useEffect(() => { useEffect(() => {
const splittedSearch = searchValue.toLowerCase().trim().split(' '); const splittedSearch = (searchValue as string).toLowerCase().trim().split(' ');
const filtered = searchHistory.filter((item: SearchHistoryItem) => { const filtered = searchHistory.filter((item: SearchHistoryItem) => {
const option = `${item.prefix} ${item.searchTerm}`; const option = `${item.prefix} ${item.searchTerm}`;
const words = option.toLowerCase().trim().split(' '); const words = option.toLowerCase().trim().split(' ');
@@ -514,8 +576,8 @@ export default function Search({
...item, ...item,
}))} }))}
filter={optionsFilter} filter={optionsFilter}
value={searchValue} value={searchValue as string}
searchValue={searchValue} searchValue={searchValue as string}
placeholder={placeholderValue} placeholder={placeholderValue}
nothingFoundMessage={ nothingFoundMessage={
<Text c={'var(--mantine-color-primary-0)'}>Nothing found...</Text> <Text c={'var(--mantine-color-primary-0)'}>Nothing found...</Text>

View File

@@ -7,7 +7,7 @@ export default function RecommendedApplications({
dropdownOpened, dropdownOpened,
filteredApplications, filteredApplications,
}: { }: {
searchValue: string; searchValue: string | number;
dropdownOpened: boolean; dropdownOpened: boolean;
filteredApplications: Application[]; filteredApplications: Application[];
}) { }) {

View File

@@ -7,7 +7,7 @@ export default function RecommendedBookmarks({
dropdownOpened, dropdownOpened,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
searchValue: string; searchValue: string | number;
dropdownOpened: boolean; dropdownOpened: boolean;
}) { }) {
return ( return (

View File

@@ -14,6 +14,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { actionIconClasses, scrollAreaClasses, tabClasses, textClasses } from 'styles'; import { actionIconClasses, scrollAreaClasses, tabClasses, textClasses } from 'styles';
import Applications from './components/Applications/Applications'; import Applications from './components/Applications/Applications';
import Bookmarks from './components/Bookmarks/Bookmarks'; import Bookmarks from './components/Bookmarks/Bookmarks';
import GuacamoleClients from './components/GuacamoleClients/GuacamoleClients';
import Messages from './components/Messages/Messages'; import Messages from './components/Messages/Messages';
import Personalization from './components/Personalization/Personalization'; import Personalization from './components/Personalization/Personalization';
import SearchHistory from './components/Search/SearchHistory'; import SearchHistory from './components/Search/SearchHistory';
@@ -162,6 +163,9 @@ export function Settings() {
<Tabs.Tab px={isMobile ? '.9rem' : undefined} value='wolClients'> <Tabs.Tab px={isMobile ? '.9rem' : undefined} value='wolClients'>
{isMobile ? <FiMonitor /> : 'WoL Clients'} {isMobile ? <FiMonitor /> : 'WoL Clients'}
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab px={isMobile ? '.9rem' : undefined} value='guacamoleClients'>
{isMobile ? <FiMonitor /> : 'Guacamole Clients'}
</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value='personalization'> <Tabs.Panel value='personalization'>
<ScrollArea <ScrollArea
@@ -235,6 +239,14 @@ export function Settings() {
<WolClients /> <WolClients />
</ScrollArea> </ScrollArea>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value='guacamoleClients'>
<ScrollArea
className={scrollAreaClasses.default}
classNames={scrollAreaClasses}
>
<GuacamoleClients />
</ScrollArea>
</Tabs.Panel>
</Tabs> </Tabs>
</Stack> </Stack>
); );

View File

@@ -60,7 +60,7 @@ const ApplicationSettings = () => {
const [applicationList, setApplicationList] = useState<Application[]>([]); const [applicationList, setApplicationList] = useState<Application[]>([]);
const { isMobile, reorder, theme } = useCommonHooks(); const { isMobile, reorder, theme } = useCommonHooks();
const { mutateAsync } = useMutationAPI(); const { mutateAsync } = useMutationAPI();
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500); const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -98,9 +98,9 @@ const ApplicationSettings = () => {
applicationData applicationData
.filter( .filter(
(a: Application) => (a: Application) =>
a.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || a.name.toLowerCase().includes((debouncedSearch as string).toLowerCase()) ||
a.url.toLowerCase().includes(debouncedSearch.toLowerCase()) || a.url.toLowerCase().includes((debouncedSearch as string).toLowerCase()) ||
a.baseUrl.toLowerCase().includes(debouncedSearch.toLowerCase()), a.baseUrl.toLowerCase().includes((debouncedSearch as string).toLowerCase()),
) )
.sort((a: Application, b: Application) => (a.index > b.index ? 1 : -1)), .sort((a: Application, b: Application) => (a.index > b.index ? 1 : -1)),
); );

View File

@@ -50,7 +50,7 @@ const BookmarkSettings = () => {
const [groupList, setGroupList] = useState<BookmarkGroup[]>([]); const [groupList, setGroupList] = useState<BookmarkGroup[]>([]);
const { isMobile, reorder, theme } = useCommonHooks(); const { isMobile, reorder, theme } = useCommonHooks();
const { mutateAsync } = useMutationAPI(); const { mutateAsync } = useMutationAPI();
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedGroupSearch] = useDebouncedValue(searchValue, 500); const [debouncedGroupSearch] = useDebouncedValue(searchValue, 500);
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -93,8 +93,12 @@ const BookmarkSettings = () => {
bookmarks bookmarks
.filter( .filter(
(a: Bookmark) => (a: Bookmark) =>
a.name.toLowerCase().includes(debouncedGroupSearch.toLowerCase()) || a.name
a.url.toLowerCase().includes(debouncedGroupSearch.toLowerCase()), .toLowerCase()
.includes((debouncedGroupSearch as string).toLowerCase()) ||
a.url
.toLowerCase()
.includes((debouncedGroupSearch as string).toLowerCase()),
) )
.map((a: Bookmark) => .map((a: Bookmark) =>
groupData.find( groupData.find(
@@ -105,7 +109,9 @@ const BookmarkSettings = () => {
), ),
]; ];
const foundGroups = groupData.filter((a: BookmarkGroup) => const foundGroups = groupData.filter((a: BookmarkGroup) =>
a.categoryName.toLowerCase().includes(debouncedGroupSearch.toLowerCase()), a.categoryName
.toLowerCase()
.includes((debouncedGroupSearch as string).toLowerCase()),
); );
const groupList = [...new Set([...foundBookmarkGroups, ...foundGroups])].sort( const groupList = [...new Set([...foundBookmarkGroups, ...foundGroups])].sort(

View File

@@ -41,7 +41,7 @@ function DebouncedGroupSettingsItem({
}: { }: {
bookmarkGroup: BookmarkGroup; bookmarkGroup: BookmarkGroup;
bookmarks: Bookmark[]; bookmarks: Bookmark[];
searchTerm: string; searchTerm: string | number;
}) { }) {
const { isMobile, handleDelete, reorder, theme } = useCommonHooks(); const { isMobile, handleDelete, reorder, theme } = useCommonHooks();
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
@@ -262,8 +262,12 @@ function DebouncedGroupSettingsItem({
> >
<Popover.Target> <Popover.Target>
<Box pos={'relative'}> <Box pos={'relative'}>
{(bookmark.name.toLowerCase().includes(searchTerm) || {(bookmark.name
bookmark.url.toLowerCase().includes(searchTerm)) && .toLowerCase()
.includes(searchTerm as string) ||
bookmark.url
.toLowerCase()
.includes(searchTerm as string)) &&
searchTerm && ( searchTerm && (
<ActionIcon <ActionIcon
variant='subtle' variant='subtle'

View File

@@ -0,0 +1,122 @@
import { ActionIcon, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
import { DeleteItemModal } from 'components';
import { useCommonHooks, useMutationAPI } from 'hooks';
import { useEffect, useState } from 'react';
import { FiTrash } from 'react-icons/fi';
import { cardClasses, textClasses, textInputClasses, toolTipClasses } from 'styles';
import type { GuacamoleClient } from 'types';
function DebouncedGuacamoleClient({
guacamoleClient,
setIsOpen,
}: {
guacamoleClient: GuacamoleClient;
setIsOpen: (isOpen: boolean) => void;
}) {
const { handleDelete, isMobile, theme } = useCommonHooks();
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const { mutateAsync } = useMutationAPI();
const [client, setClient] = useState<GuacamoleClient>(guacamoleClient);
const [update, setUpdate] = useState<any>({});
const [debounced] = useDebouncedValue(update, 1000);
const handleClose = () => {
setIsOpen(false);
closeModal();
};
useEffect(() => {
if (Object.keys(debounced).length) {
mutateAsync({ method: 'PATCH', endpoint: 'api/guacamoleClients', body: debounced });
}
}, [debounced]);
useEffect(() => {
if (update !== client && Object.keys(update).length) {
setClient(update);
}
}, [update]);
return (
<Card
p={isMobile ? 'xl' : undefined}
className={cardClasses.settingsItem}
classNames={cardClasses}
>
<DeleteItemModal
modalOpened={modalOpened}
handleClose={handleClose}
handleDelete={() => handleDelete(client, 'api/guacamoleClients')}
title={'Delete Guacamole Client'}
body={
<Stack gap={0} w={'100%'} h={'150px'} align='center'>
<Text>Are you sure you want to delete this WoL Client?</Text>
<Text
className={textClasses.deleteItem}
classNames={textClasses}
>{`"${client.clientName}"`}</Text>
<Text c='red'>This action cannot be undone!</Text>
</Stack>
}
/>
<Tooltip
className={toolTipClasses.default}
classNames={toolTipClasses}
label={'Delete'}
>
<ActionIcon
pos={'absolute'}
right={'1rem'}
color='red'
onClick={() => {
openModal();
}}
>
<FiTrash color='black' />
</ActionIcon>
</Tooltip>
<Group w={'100%'} justify='center'>
<TextInput
label={'Client Name'}
className={textInputClasses.settingsItem}
classNames={textInputClasses}
defaultValue={client.clientName}
onChange={(event) => {
setUpdate({
...client,
clientName: event.target.value,
});
}}
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
<TextInput
label={'Client Identifier'}
className={textInputClasses.settingsItem}
classNames={textInputClasses}
defaultValue={client.clientIdentifier}
onChange={(event) => {
setUpdate({
...client,
clientIdentifier: event.target.value,
});
}}
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
</Group>
</Card>
);
}
export default DebouncedGuacamoleClient;

View File

@@ -0,0 +1,23 @@
import { Card, Stack, Text } from '@mantine/core';
import { FaLaptop } from 'react-icons/fa';
import { cardClasses } from 'styles';
import type { GuacamoleClient } from 'types';
function GuacamoleClientSettingsItem({ client }: { client: GuacamoleClient }) {
return (
<Card
className={cardClasses.settingsItemHover}
classNames={cardClasses}
h={200}
w={'100%'}
>
<Stack align='center' gap={0}>
<FaLaptop fontSize={100} />
<Text fz={'1.5rem'}>{client.clientName}</Text>
<Text fz={'1.25rem'}>{client.clientIdentifier}</Text>
</Stack>
</Card>
);
}
export default GuacamoleClientSettingsItem;

View File

@@ -0,0 +1,211 @@
import {
Button,
Grid,
GridCol,
Group,
LoadingOverlay,
Popover,
Stack,
TextInput,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
import { AddItemModal, NoItemsFound, SettingsHeader } from 'components';
import { useCommonHooks, useMutationAPI, useQueryAPI } from 'hooks';
import { useEffect, useState } from 'react';
import { popoverClasses, textInputClasses } from 'styles';
import type { GuacamoleClient } from 'types';
import DebouncedGuacamoleClient from './DebouncedGuacamoleClient';
import GuacamoleClientSettingsItem from './GuacamoleClientSettingsItem';
const GuacamoleClients = () => {
const defaultGuacamoleClient: GuacamoleClient = {
id: undefined,
clientName: '',
clientIdentifier: '',
enabled: 1,
};
const form = useForm({
initialValues: defaultGuacamoleClient,
validate: {
clientName: isNotEmpty('Client Name is required'),
clientIdentifier: isNotEmpty('Client Identifier is required'),
},
});
const endpoint = 'api/guacamoleClients';
const {
data: guacamoleClientsData,
isFetched: guacamoleClientsIsFetched,
isFetching: guacamoleClientsIsFetching,
} = useQueryAPI({
endpoint: endpoint,
});
const [guacamoleClients, setGuacamoleClients] = useState<GuacamoleClient[]>([]);
const { mutateAsync } = useMutationAPI();
const { isMobile, theme } = useCommonHooks();
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleClose = () => {
closeModal();
form.reset();
};
useEffect(() => {
if (guacamoleClientsData && guacamoleClientsIsFetched) {
setGuacamoleClients(
guacamoleClientsData.filter((a: GuacamoleClient) =>
a.clientName.toLowerCase().includes((debouncedSearch as string).toLowerCase()),
),
);
}
if (debouncedSearch === searchValue) {
setIsLoading(false);
}
}, [guacamoleClientsData, guacamoleClientsIsFetched, debouncedSearch]);
return (
<Stack>
<AddItemModal
modalOpened={modalOpened}
handleClose={handleClose}
title={'Add a new Guacamole Client'}
body={
<form
onSubmit={form.onSubmit((values) => {
mutateAsync({
method: 'POST',
endpoint: endpoint,
body: { ...values, enabled: 1 },
});
handleClose();
})}
>
<Stack gap={0} w={'100%'} align='center'>
<Group w={'100%'} justify='center'>
<TextInput
key={form.key('clientName')}
{...form.getInputProps('clientName')}
label={'Client Name'}
placeholder='eg. Workstation'
className={textInputClasses.settingsItem}
classNames={textInputClasses}
withAsterisk
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
<TextInput
key={form.key('clientIdentifier')}
{...form.getInputProps('clientIdentifier')}
label={'clientIdentifier'}
placeholder='eg. MzIAYwBteXNxbA'
className={textInputClasses.settingsItem}
classNames={textInputClasses}
withAsterisk
styles={
theme.textShadowsEnabled
? {
label: { boxShadow: '2px 2px var(--mantine-color-border-0)' },
}
: undefined
}
/>
</Group>
<Group mt={'1rem'}>
<Button type='submit' color={'green'}>
Save
</Button>
<Button
color={'red'}
styles={{ label: { color: 'black' } }}
onClick={handleClose}
>
Cancel
</Button>
</Group>
</Stack>
</form>
}
/>
<SettingsHeader
setDragging={undefined}
openModal={openModal}
title={'Guacamole Clients'}
tooltip={{
title: 'Guacamole clients can be configured here.',
highlightList: ['Clients', 'homepage', 'Guacamole'],
description: 'Clients can be connected to from the homepage.',
}}
searchValue={searchValue}
setSearchValue={setSearchValue}
searchPlaceholder={'Search Clients...'}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
<Stack
mt={isMobile ? '10rem' : '6rem'}
mb={isMobile ? '6rem' : '1rem'}
w={'100%'}
align='center'
>
<LoadingOverlay
visible={guacamoleClientsIsFetching}
zIndex={180}
overlayProps={{ blur: 5 }}
loaderProps={{
color: 'var(--mantine-color-primary-0)',
type: 'bars',
size: 'xl',
}}
/>
<Grid columns={18} w={'75%'}>
{guacamoleClients.length > 0 ? (
guacamoleClients?.map((client: GuacamoleClient) => (
<GridCol w={'100%'} span={isMobile ? 18 : 6} key={client.id}>
<Popover
withArrow
withOverlay
trapFocus
closeOnClickOutside={!isOpen}
classNames={popoverClasses}
middlewares={{ shift: { padding: 20 } }}
position='bottom'
offset={isMobile ? -100 : 0}
>
<Popover.Target>
<Group justify='center'>
<GuacamoleClientSettingsItem client={client} />
</Group>
</Popover.Target>
<Popover.Dropdown w={isMobile ? '90%' : undefined}>
<DebouncedGuacamoleClient
guacamoleClient={client}
setIsOpen={setIsOpen}
/>
</Popover.Dropdown>
</Popover>
</GridCol>
))
) : (
<GridCol
w={'100%'}
span={18}
style={{ display: 'flex', justifyContent: 'center' }}
>
<NoItemsFound />
</GridCol>
)}
</Grid>
</Stack>
</Stack>
);
};
export default GuacamoleClients;

View File

@@ -38,7 +38,7 @@ const MessageSettings = () => {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [message, setMessage] = useState({}); const [message, setMessage] = useState({});
const [debounced] = useDebouncedValue(message, 1000); const [debounced] = useDebouncedValue(message, 1000);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500); const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -52,7 +52,7 @@ const MessageSettings = () => {
if (messageData && messageDataIsFetched) { if (messageData && messageDataIsFetched) {
setMessages( setMessages(
messageData.filter((m: Message) => messageData.filter((m: Message) =>
m.message.toLowerCase().includes(debouncedSearch.toLowerCase()), m.message.toLowerCase().includes((debouncedSearch as string).toLowerCase()),
), ),
); );
} }

View File

@@ -17,7 +17,7 @@ const SearchHistory = () => {
endpoint: searchEndpoint, endpoint: searchEndpoint,
}); });
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]); const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [searchHistoryItem, setSearchHistoryItem] = useState<SearchHistoryItem>(); const [searchHistoryItem, setSearchHistoryItem] = useState<SearchHistoryItem>();
const [debouncedSearch] = useDebouncedValue(searchValue, 500); const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);

View File

@@ -63,7 +63,7 @@ const SearchProviderSettings = () => {
const { isMobile, theme, iconFilter } = useCommonHooks(); const { isMobile, theme, iconFilter } = useCommonHooks();
const { mutateAsync } = useMutationAPI(); const { mutateAsync } = useMutationAPI();
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500); const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -79,8 +79,8 @@ const SearchProviderSettings = () => {
providerData providerData
.filter( .filter(
(a: Provider) => (a: Provider) =>
a.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || a.name.toLowerCase().includes((debouncedSearch as string).toLowerCase()) ||
a.url.toLowerCase().includes(debouncedSearch.toLowerCase()), a.url.toLowerCase().includes((debouncedSearch as string).toLowerCase()),
) )
.sort((a: Provider, b: Provider) => (a.prefix > b.prefix ? 1 : -1)), .sort((a: Provider, b: Provider) => (a.prefix > b.prefix ? 1 : -1)),
); );

View File

@@ -34,7 +34,7 @@ export function ThemePlaylists() {
const [selectedPlaylist, setSelectedPlaylist] = const [selectedPlaylist, setSelectedPlaylist] =
useState<ThemePlaylist>(defaultPlaylist); useState<ThemePlaylist>(defaultPlaylist);
const { currentThemePlaylist, theme, isMobile } = useCommonHooks(); const { currentThemePlaylist, theme, isMobile } = useCommonHooks();
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500); const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -62,7 +62,7 @@ export function ThemePlaylists() {
setThemePlaylists( setThemePlaylists(
themePlaylistData.filter( themePlaylistData.filter(
(a: ThemePlaylist) => (a: ThemePlaylist) =>
a.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || a.name.toLowerCase().includes((debouncedSearch as string).toLowerCase()) ||
a.name.includes('Time of Day'), a.name.includes('Time of Day'),
), ),
); );

View File

@@ -65,7 +65,7 @@ export function Themes() {
textShadowsEnabled: 0, textShadowsEnabled: 0,
appShadowsEnabled: 0, appShadowsEnabled: 0,
}); });
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500); const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isNew, setIsNew] = useState(false); const [isNew, setIsNew] = useState(false);
@@ -103,7 +103,7 @@ export function Themes() {
if (allThemeData && allThemeDataIsFetched) { if (allThemeData && allThemeDataIsFetched) {
setAllThemes( setAllThemes(
allThemeData.filter((a: Theme) => allThemeData.filter((a: Theme) =>
a.name.toLowerCase().includes(debouncedSearch.toLowerCase()), a.name.toLowerCase().includes((debouncedSearch as string).toLowerCase()),
), ),
); );
} }

View File

@@ -46,7 +46,7 @@ const WolClientSettings = () => {
const { mutateAsync } = useMutationAPI(); const { mutateAsync } = useMutationAPI();
const { isMobile, theme } = useCommonHooks(); const { isMobile, theme } = useCommonHooks();
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false); const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState<string | number>('');
const [debouncedSearch] = useDebouncedValue(searchValue, 500); const [debouncedSearch] = useDebouncedValue(searchValue, 500);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -60,7 +60,7 @@ const WolClientSettings = () => {
if (wolClientsData && wolClientsIsFetched) { if (wolClientsData && wolClientsIsFetched) {
setWolClients( setWolClients(
wolClientsData.filter((a: WolClient) => wolClientsData.filter((a: WolClient) =>
a.hostname.toLowerCase().includes(debouncedSearch.toLowerCase()), a.hostname.toLowerCase().includes((debouncedSearch as string).toLowerCase()),
), ),
); );
} }

View File

@@ -0,0 +1,6 @@
export interface GuacamoleClient {
id: number | undefined;
clientName: string;
clientIdentifier: string;
enabled: number;
}

View File

@@ -1,6 +1,7 @@
export type { AppCategory, Application } from './application'; export type { AppCategory, Application } from './application';
export type { Bookmark } from './bookmark'; export type { Bookmark } from './bookmark';
export type { BookmarkGroup } from './bookmarkGroup'; export type { BookmarkGroup } from './bookmarkGroup';
export type { GuacamoleClient } from './guacamoleClient';
export type { Location } from './location'; export type { Location } from './location';
export type { Message } from './message'; export type { Message } from './message';
export type { PersonalData } from './personalData'; export type { PersonalData } from './personalData';