Migrate from ESLint to Oxlint and Oxfmt

This commit is contained in:
Alejandro Celaya
2026-07-26 09:12:18 +02:00
parent 2cf85a18b3
commit 54d4d9ebad
81 changed files with 2004 additions and 3790 deletions

View File

@@ -9,14 +9,18 @@ const apiClients: Map<string, ShlinkApiClient> = new Map();
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
const { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
throw new Error('There\'s no selected server or it is not found');
throw new Error("There's no selected server or it is not found");
}
return selectedServer;
};
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
const { url: baseUrl, apiKey, forwardCredentials } = typeof getStateOrSelectedServer === 'function'
const {
url: baseUrl,
apiKey,
forwardCredentials,
} = typeof getStateOrSelectedServer === 'function'
? getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer;
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;

View File

@@ -47,13 +47,17 @@ export const App: FC = () => {
<Routes>
<Route index element={<Home />} />
<Route path="/settings">
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
{['', '*'].map((path) => (
<Route key={path} path={path} element={<Settings />} />
))}
</Route>
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId">
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
{['', '*'].map((path) => (
<Route key={path} path={path} element={<ShlinkWebComponentContainer />} />
))}
</Route>
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -1,6 +1,6 @@
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Card, CloseButton, useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useCallback } from 'react';
@@ -25,10 +25,7 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
return (
<Card
role="alert"
className={clsx(
'w-[700px] max-w-[calc(100%-30px)]',
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
)}
className={clsx('w-[700px] max-w-[calc(100%-30px)]', 'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]')}
>
<Card.Header className="flex items-center justify-between">
<h5>This app has just been updated!</h5>
@@ -37,7 +34,11 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
Restart it to enjoy the new features.
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
{!isUpdating && (
<>
Restart now <FontAwesomeIcon icon={reloadIcon} />
</>
)}
{isUpdating && <>Restarting...</>}
</Button>
</Card.Body>

View File

@@ -36,7 +36,9 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
<ErrorLayout title="Oops! This is awkward :S">
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br />
<Button size="lg" onClick={() => location.reload()}>Take me back</Button>
<Button size="lg" onClick={() => location.reload()}>
Take me back
</Button>
</ErrorLayout>
);
}

View File

@@ -36,15 +36,12 @@ export const Home: FC = withoutSelectedServer(() => {
</div>
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
<h1
className={clsx(
'p-4 text-center border-lm-border dark:border-dm-border',
{ 'border-b': !hasServers },
)}
>
<h1 className={clsx('p-4 text-center border-lm-border dark:border-dm-border', { 'border-b': !hasServers })}>
Welcome!
</h1>
{hasServers ? <ServersListGroup servers={serversList} /> : (
{hasServers ? (
<ServersListGroup servers={serversList} />
) : (
<div className="p-6 text-center flex flex-col gap-12 text-xl">
<p>This application will help you manage your Shlink servers.</p>
<p>

View File

@@ -14,11 +14,11 @@ export const MainHeader: FC = () => {
return (
<NavBar
className="[&]:fixed top-0 z-900"
brand={(
brand={
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
</Link>
)}
}
>
<NavBar.MenuItem
to={settingsPath}

View File

@@ -6,7 +6,5 @@ export type NoMenuLayoutProps = PropsWithChildren & {
};
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>
{children}
</div>
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>{children}</div>
);

View File

@@ -11,6 +11,8 @@ export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) =>
button.
</p>
<br />
<Button inline to={to} size="lg">{children}</Button>
<Button inline to={to} size="lg">
{children}
</Button>
</ErrorLayout>
);

View File

@@ -23,7 +23,9 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
return (
<small className="text-gray-500">
{isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
<>
Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> -{' '}
</>
)}
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
</small>

View File

@@ -6,9 +6,7 @@ import { ShlinkVersions } from './ShlinkVersions';
export const ShlinkVersionsContainer = () => {
const { selectedServer } = useSelectedServer();
return (
<div
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
>
<div className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}>
<ShlinkVersions selectedServer={selectedServer} />
</div>
);

View File

@@ -20,41 +20,39 @@ export type ShlinkWebComponentContainerProps = {
buildShlinkApiClient: ShlinkApiClientBuilder;
};
const ShlinkWebComponentContainerBase: FC<
ShlinkWebComponentContainerProps
// FIXME Using `memo` here to solve a flickering effect in charts.
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
// extra rendering there.
// This should be revisited at some point.
> = withSelectedServer(memo(({
buildShlinkApiClient,
TagColorsStorage: tagColorsStorage,
}) => {
const { selectedServer } = useSelectedServer();
const { settings } = useSettings();
const ShlinkWebComponentContainerBase: FC<ShlinkWebComponentContainerProps> =
// FIXME Using `memo` here to solve a flickering effect in charts.
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
// extra rendering there.
// This should be revisited at some point.
withSelectedServer(
memo(({ buildShlinkApiClient, TagColorsStorage: tagColorsStorage }) => {
const { selectedServer } = useSelectedServer();
const { settings } = useSettings();
if (!isReachableServer(selectedServer)) {
return <ServerError />;
}
if (!isReachableServer(selectedServer)) {
return <ServerError />;
}
const routesPrefix = `/server/${selectedServer.id}`;
return (
<ShlinkSidebarVisibilityProvider>
<ShlinkSidebarToggleButton className="fixed top-3.5 left-3 z-901" />
<ShlinkWebComponent
serverVersion={selectedServer.version}
apiClient={buildShlinkApiClient(selectedServer)}
settings={settings}
routesPrefix={routesPrefix}
tagColorsStorage={tagColorsStorage}
createNotFound={(nonPrefixedHomePath: string) => (
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
)}
autoSidebarToggle={false}
/>
</ShlinkSidebarVisibilityProvider>
const routesPrefix = `/server/${selectedServer.id}`;
return (
<ShlinkSidebarVisibilityProvider>
<ShlinkSidebarToggleButton className="fixed top-3.5 left-3 z-901" />
<ShlinkWebComponent
serverVersion={selectedServer.version}
apiClient={buildShlinkApiClient(selectedServer)}
settings={settings}
routesPrefix={routesPrefix}
tagColorsStorage={tagColorsStorage}
createNotFound={(nonPrefixedHomePath: string) => (
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
)}
autoSidebarToggle={false}
/>
</ShlinkSidebarVisibilityProvider>
);
}),
);
}));
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
'buildShlinkApiClient',

View File

@@ -8,18 +8,10 @@ export interface ShlinkLogoProps {
export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => (
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill={color}>
<path
d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z"
/>
<path
d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z"
/>
<path
d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z"
/>
<path
d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z"
/>
<path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" />
<path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" />
<path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" />
<path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" />
</g>
</svg>
);

View File

@@ -36,10 +36,7 @@ type Optionalize<P, K extends keyof P> = Omit<P, K> & Partial<Pick<P, K>>;
* Higher Order Component used to inject services into components as props.
* All dependencies become optional props so that they can still be explicitly set in tests if desired.
*/
export function withDependencies<
Props extends Record<string, unknown>,
DependencyName extends string & keyof Props,
>(
export function withDependencies<Props extends Record<string, unknown>, DependencyName extends string & keyof Props>(
Component: ComponentType<Props>,
dependencyNames: DependencyName[],
): ComponentType<Optionalize<Props, DependencyName>> {

View File

@@ -38,25 +38,31 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((newServerData: ServerData) => {
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
const saveNewServer = useCallback(
(newServerData: ServerData) => {
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
createServers([newServerWithUniqueId]);
navigate(`/server/${newServerWithUniqueId.id}`);
}, [createServers, navigate, servers]);
const onSubmit = useCallback((newServerData: ServerData) => {
setServerData(newServerData);
createServers([newServerWithUniqueId]);
navigate(`/server/${newServerWithUniqueId.id}`);
},
[createServers, navigate, servers],
);
const onSubmit = useCallback(
(newServerData: ServerData) => {
setServerData(newServerData);
const serverExists = Object.values(servers).some(
({ url, apiKey }) => newServerData.url === url && newServerData.apiKey === apiKey,
);
const serverExists = Object.values(servers).some(
({ url, apiKey }) => newServerData.url === url && newServerData.apiKey === apiKey,
);
if (serverExists) {
toggleConfirmModal();
} else {
saveNewServer(newServerData);
}
}, [saveNewServer, servers, toggleConfirmModal]);
if (serverExists) {
toggleConfirmModal();
} else {
saveNewServer(newServerData);
}
},
[saveNewServer, servers, toggleConfirmModal],
);
return (
<NoMenuLayout>
@@ -64,7 +70,11 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
{!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
)}
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
{hasServers && (
<Button variant="secondary" onClick={goBack}>
Cancel
</Button>
)}
<Button type="submit">Create server</Button>
</ServerForm>

View File

@@ -12,12 +12,15 @@ export type DeleteServerButtonProps = PropsWithChildren<{
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
const navigate = useNavigate();
const onClose = useCallback((confirmed: boolean) => {
hideModal();
if (confirmed) {
navigate('/');
}
}, [hideModal, navigate]);
const onClose = useCallback(
(confirmed: boolean) => {
hideModal();
if (confirmed) {
navigate('/');
}
},
[hideModal, navigate],
);
return (
<>

View File

@@ -13,11 +13,14 @@ export type DeleteServerModalProps = {
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
const { deleteServer } = useServers();
const onClosed = useCallback((exitAction: ExitAction) => {
if (exitAction === 'confirm') {
deleteServer(server);
}
}, [deleteServer, server]);
const onClosed = useCallback(
(exitAction: ExitAction) => {
if (exitAction === 'confirm') {
deleteServer(server);
}
},
[deleteServer, server],
);
return (
<CardModal
@@ -30,11 +33,13 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
confirmText="Delete"
>
<div className="flex flex-col gap-y-4">
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
Are you sure you want to remove <b>{server ? server.name : ''}</b>?
</p>
<p>
<i>
No data will be deleted, only the access to this server will be removed from this device.
You can create it again at any moment.
No data will be deleted, only the access to this server will be removed from this device. You can create it
again at any moment.
</i>
</p>
</div>

View File

@@ -34,7 +34,9 @@ export const EditServer: FC = withSelectedServer(() => {
initialValues={selectedServer}
onSubmit={handleSubmit}
>
<Button variant="secondary" onClick={goBack}>Cancel</Button>
<Button variant="secondary" onClick={goBack}>
Cancel
</Button>
<Button type="submit">Save</Button>
</ServerForm>
</NoMenuLayout>

View File

@@ -19,66 +19,75 @@ export type ManageServersProps = {
const SHOW_IMPORT_MSG_TIME = 4000;
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
ServersExporter: serversExporter,
useTimeoutToggle,
}) => {
const { servers } = useServers();
const [searchTerm, setSearchTerm] = useState('');
const allServers = useMemo(() => Object.values(servers), [servers]);
const filteredServers = useMemo(
() => allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
[allServers, searchTerm],
);
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(
({ ServersExporter: serversExporter, useTimeoutToggle }) => {
const { servers } = useServers();
const [searchTerm, setSearchTerm] = useState('');
const allServers = useMemo(() => Object.values(servers), [servers]);
const filteredServers = useMemo(
() => allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
[allServers, searchTerm],
);
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
return (
<NoMenuLayout className="flex flex-col gap-y-4">
<SearchInput onChange={setSearchTerm} />
return (
<NoMenuLayout className="flex flex-col gap-y-4">
<SearchInput onChange={setSearchTerm} />
<div className="flex flex-col md:flex-row gap-2">
<div className="flex gap-2">
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && (
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
</Button>
)}
</div>
<Button className="md:ml-auto" to="/server/create">
<FontAwesomeIcon icon={plusIcon} widthAuto /> Add a server
</Button>
</div>
<SimpleCard className="card">
<Table header={(
<Table.Row>
{hasAutoConnect && (
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell>
<div className="flex flex-col md:flex-row gap-2">
<div className="flex gap-2">
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>
Import servers
</ImportServersBtn>
{filteredServers.length > 0 && (
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
</Button>
)}
<Table.Cell>Name</Table.Cell>
<Table.Cell>Base URL</Table.Cell>
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
</Table.Row>
)}>
{!filteredServers.length && (
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
)}
{filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
))}
</Table>
</SimpleCard>
{errorImporting && (
<div>
<Result variant="error">The servers could not be imported. Make sure the format is correct.</Result>
</div>
<Button className="md:ml-auto" to="/server/create">
<FontAwesomeIcon icon={plusIcon} widthAuto /> Add a server
</Button>
</div>
)}
</NoMenuLayout>
);
});
<SimpleCard className="card">
<Table
header={
<Table.Row>
{hasAutoConnect && (
<Table.Cell className="w-[35px]">
<span className="sr-only">Auto-connect</span>
</Table.Cell>
)}
<Table.Cell>Name</Table.Cell>
<Table.Cell>Base URL</Table.Cell>
<Table.Cell>
<span className="sr-only">Options</span>
</Table.Cell>
</Table.Row>
}
>
{!filteredServers.length && (
<Table.Row className="text-center">
<Table.Cell colSpan={4}>No servers found.</Table.Cell>
</Table.Row>
)}
{filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
))}
</Table>
</SimpleCard>
{errorImporting && (
<div>
<Result variant="error">The servers could not be imported. Make sure the format is correct.</Result>
</div>
)}
</NoMenuLayout>
);
},
);
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']);

View File

@@ -34,7 +34,9 @@ export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoCon
<Table.Cell className="font-bold" columnName="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link>
</Table.Cell>
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">{server.url}</Table.Cell>
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">
{server.url}
</Table.Cell>
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0">
<ManageServersRowDropdown server={server} />
</Table.Cell>

View File

@@ -12,11 +12,13 @@ export const ServersDropdown: FC = () => {
const { selectedServer } = useSelectedServer();
return (
<NavBar.Dropdown buttonContent={(
<span className="flex items-center gap-1.5">
<FontAwesomeIcon icon={serverIcon} /> Servers
</span>
)}>
<NavBar.Dropdown
buttonContent={
<span className="flex items-center gap-1.5">
<FontAwesomeIcon icon={serverIcon} /> Servers
</span>
}
>
{serversList.length === 0 ? (
<Dropdown.Item to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> Add a server

View File

@@ -25,7 +25,7 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
</Link>
);
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) =>
servers.length > 0 && (
<div
data-testid="list"
@@ -35,7 +35,8 @@ export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderles
{ 'border-y': !borderless },
)}
>
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
{servers.map(({ id, name }) => (
<ServerListItem key={id} id={id} name={name} />
))}
</div>
)
);
);

View File

@@ -10,9 +10,12 @@ export type DuplicatedServersModalProps = {
onConfirm: () => void;
};
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
{ open, duplicatedServers, onClose, onConfirm },
) => {
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = ({
open,
duplicatedServers,
onClose,
onConfirm,
}) => {
const hasMultipleServers = duplicatedServers.length > 1;
return (
@@ -27,16 +30,24 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul className="list-disc my-4 pl-5">
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}>
<li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li>
</Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
{duplicatedServers.map(({ url, apiKey }, index) =>
!hasMultipleServers ? (
<Fragment key={index}>
<li>
URL: <b>{url}</b>
</li>
<li>
API key: <b>{apiKey}</b>
</li>
</Fragment>
) : (
<li key={index}>
<b>{url}</b> - <b>{apiKey}</b>
</li>
),
)}
</ul>
<span>
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
</span>
<span>{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?</span>
</CardModal>
);
};

View File

@@ -17,7 +17,7 @@ export type ImportServersBtnProps = PropsWithChildren<{
className?: string;
// Injected
ServersImporter: ServersImporter
ServersImporter: ServersImporter;
}>;
const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
@@ -37,7 +37,8 @@ const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0])
serversImporter
.importServersFromFile(target.files?.[0])
.then((importedServers) => {
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);

View File

@@ -26,8 +26,8 @@ export const ServerError: FC = () => {
</Message>
<p className="text-xl">
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
These are the Shlink servers currently configured. Choose one of them or{' '}
<Link to="/server/create">add a new one</Link>.
</p>
<Card className="w-full max-w-100 overflow-hidden">
<ServersListGroup borderless servers={Object.values(servers)} />
@@ -35,9 +35,8 @@ export const ServerError: FC = () => {
{isServerWithId(selectedServer) && (
<p className="text-xl">
Alternatively, if you think you may have misconfigured this server, you
can <DeleteServerButton server={selectedServer}>remove
it</DeleteServerButton> or&nbsp;
Alternatively, if you think you may have misconfigured this server, you can{' '}
<DeleteServerButton server={selectedServer}>remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p>
)}

View File

@@ -45,13 +45,13 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
Forward credentials to this server on every request.
</Label>
<small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5">
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username
and password.
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a
username and password.
</small>
<small className="pl-5.5 text-gray-600 dark:text-gray-400">
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will
make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict
value for <code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will make
all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict value for{' '}
<code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
</small>
</div>
</Details>

View File

@@ -49,9 +49,9 @@ export type DedupServersResult = {
*/
export function dedupServers(servers: ServersMap, serversToAdd: ServerData[]): DedupServersResult {
const serversList = Object.values(servers);
const { duplicatedServers = [], newServers = [] } = groupBy(
serversToAdd,
(server) => serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
// oxlint-disable-next-line typescript/no-useless-default-assignment - Type definition is wrong
const { duplicatedServers = [], newServers = [] } = groupBy(serversToAdd, (server) =>
serversInclude(serversList, server) ? 'duplicatedServers' : 'newServers',
);
return { duplicatedServers, newServers };

View File

@@ -8,10 +8,7 @@ import { hasServerData } from '../data';
import { ensureUniqueIds } from '../helpers';
import { createServers, useServers } from './servers';
const responseToServersList = (data: any) => ensureUniqueIds(
{},
(Array.isArray(data) ? data.filter(hasServerData) : []),
);
const responseToServersList = (data: any) => ensureUniqueIds({}, Array.isArray(data) ? data.filter(hasServerData) : []);
export const fetchServers = createAsyncThunk(
'shlink/remoteServers/fetchServers',

View File

@@ -15,17 +15,16 @@ export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest';
const versionToSemVer = (version: string) => toSemVer(
version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
MIN_FALLBACK_VERSION,
);
const versionToSemVer = (version: string) =>
toSemVer(version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version, MIN_FALLBACK_VERSION);
const getServerVersion = memoizeWith(
(server: ServerWithId) => `${server.id}_${server.url}_${server.apiKey}`,
async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
version: versionToSemVer(version),
printableVersion: versionToPrintable(version),
})),
async (_server: ServerWithId, health: () => Promise<ShlinkHealth>) =>
health().then(({ version }) => ({
version: versionToSemVer(version),
printableVersion: versionToPrintable(version),
})),
);
const initialState: SelectedServer = null;

View File

@@ -27,9 +27,7 @@ export const { actions, reducer: serversReducer } = createSlice({
}),
reducer: (state, { payload }: PayloadAction<EditServer>) => {
const { serverId, serverData } = payload;
return (
!state[serverId] ? state : { ...state, [serverId]: { ...state[serverId], ...serverData } }
);
return !state[serverId] ? state : { ...state, [serverId]: { ...state[serverId], ...serverData } };
},
},
deleteServer: (state, { payload }) => {

View File

@@ -13,10 +13,10 @@ import pack from '../package.json';
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
);
type Config = {
@@ -110,10 +110,7 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {

View File

@@ -4,9 +4,5 @@ import type { RootState } from '.';
export const createAsyncThunk = <Returned, ThunkArg>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: RootState, serializedErrorType: any }>,
) => baseCreateAsyncThunk(
typePrefix,
payloadCreator,
{ serializeError: (e) => e },
);
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: RootState; serializedErrorType: any }>,
) => baseCreateAsyncThunk(typePrefix, payloadCreator, { serializeError: (e) => e });

View File

@@ -14,13 +14,14 @@ const localStorageConfig: RLSOptions = {
const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig));
const isProduction = process.env.NODE_ENV === 'production';
export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({
devTools: !isProduction,
reducer: initReducers(),
preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)),
});
export const setUpStore = (preloadedState = getStateFromLocalStorage()) =>
configureStore({
devTools: !isProduction,
reducer: initReducers(),
preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)),
});
export type StoreType = ReturnType<typeof setUpStore>;
export type AppDispatch = StoreType['dispatch'];

View File

@@ -4,9 +4,10 @@ import { selectedServerReducer } from '../servers/reducers/selectedServer';
import { serversReducer } from '../servers/reducers/servers';
import { settingsReducer } from '../settings/reducers/settings';
export const initReducers = () => combineReducers({
appUpdated: appUpdatesReducer,
servers: serversReducer,
selectedServer: selectedServerReducer,
settings: settingsReducer,
});
export const initReducers = () =>
combineReducers({
appUpdated: appUpdatesReducer,
servers: serversReducer,
selectedServer: selectedServerReducer,
settings: settingsReducer,
});

View File

@@ -5,8 +5,8 @@
@source '../node_modules/@shlinkio/shlink-web-component';
@layer base {
:root {
--footer-height: 2.3rem;
--footer-margin: .8rem;
}
:root {
--footer-height: 2.3rem;
--footer-margin: 0.8rem;
}
}

View File

@@ -1,9 +1,10 @@
import { Parser } from '@json2csv/plainjs';
import csv from 'csvtojson';
export const csvToJson = <T>(csvContent: string) => new Promise<T[]>((resolve) => {
csv().fromString(csvContent).then(resolve);
});
export const csvToJson = <T>(csvContent: string) =>
new Promise<T[]>((resolve) => {
csv().fromString(csvContent).then(resolve);
});
export type CsvToJson = typeof csvToJson;

View File

@@ -1,5 +1,5 @@
export const forceUpdate = async () => {
const registrations = await navigator.serviceWorker?.getRegistrations() ?? [];
const registrations = (await navigator.serviceWorker?.getRegistrations()) ?? [];
registrations.forEach(({ waiting }) => {
waiting?.addEventListener('statechange', (event) => {

View File

@@ -3,19 +3,19 @@ import { compare } from 'compare-versions';
export type Empty = null | undefined | '' | never[];
const isEmpty = (value: Exclude<any, undefined | null>): boolean => (
(Array.isArray(value) && value.length === 0)
|| (typeof value === 'string' && value === '')
|| (typeof value === 'object' && Object.keys(value).length === 0)
);
const isEmpty = (value: Exclude<any, undefined | null>): boolean =>
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'string' && value === '') ||
(typeof value === 'object' && Object.keys(value).length === 0);
export const hasValue = <T>(value: T | Empty): value is T => value !== undefined && value !== null && !isEmpty(value);
type SemVerPatternFragment = `${bigint | '*'}`;
type SemVerPattern = SemVerPatternFragment
| `${SemVerPatternFragment}.${SemVerPatternFragment}`
| `${SemVerPatternFragment}.${SemVerPatternFragment}.${SemVerPatternFragment}`;
type SemVerPattern =
| SemVerPatternFragment
| `${SemVerPatternFragment}.${SemVerPatternFragment}`
| `${SemVerPatternFragment}.${SemVerPatternFragment}.${SemVerPatternFragment}`;
export type Versions = {
maxVersion?: SemVerPattern;
@@ -35,16 +35,18 @@ export const versionMatch = (versionToMatch: SemVer | Empty, { maxVersion, minVe
return matchesMaxVersion && matchesMinVersion;
};
const versionIsValidSemVer = memoizeWith((v) => v, (version: string): version is SemVer => {
try {
return compare(version, version, '=');
} catch {
return false;
}
});
const versionIsValidSemVer = memoizeWith(
(v) => v,
(version: string): version is SemVer => {
try {
return compare(version, version, '=');
} catch {
return false;
}
},
);
export const versionToPrintable = (version: string) => (!versionIsValidSemVer(version) ? version : `v${version}`);
export const versionToSemVer = (version: string, fallback: SemVer = 'latest'): SemVer => (
versionIsValidSemVer(version) ? version : fallback
);
export const versionToSemVer = (version: string, fallback: SemVer = 'latest'): SemVer =>
versionIsValidSemVer(version) ? version : fallback;

View File

@@ -6,7 +6,7 @@ export class LocalStorage {
public readonly get = <T>(key: string): T | undefined => {
const item = this.localStorage.getItem(buildPath(key));
return item ? JSON.parse(item) as T : undefined;
return item ? (JSON.parse(item) as T) : undefined;
};
public readonly set = (key: string, value: any) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));

View File

@@ -2,8 +2,7 @@ import type { TagColorsStorage as BaseTagColorsStorage } from '@shlinkio/shlink-
import type { LocalStorage } from './LocalStorage';
export class TagColorsStorage implements BaseTagColorsStorage {
constructor(private readonly storage: LocalStorage) {
}
constructor(private readonly storage: LocalStorage) {}
getTagColors(): Record<string, string> {
return this.storage.get<Record<string, string>>('colors') ?? {};

View File

@@ -5,7 +5,10 @@ import { useCallback } from 'react';
* Wraps an event handler so that it calls e.preventDefault() before invoking the event handler
*/
export const usePreventDefault = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
useCallback((e: Event) => {
e.preventDefault();
handler(e);
}, [handler]);
useCallback(
(e: Event) => {
e.preventDefault();
handler(e);
},
[handler],
);