diff --git a/index.html b/index.html index e4b78ea..37af710 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Lynx Scanner
diff --git a/openapi-config.ts b/openapi-config.ts index c866b57..6ec9432 100644 --- a/openapi-config.ts +++ b/openapi-config.ts @@ -4,7 +4,7 @@ const baseDir = process.env.GEN_FOLDER!; const config: ConfigFile = { schemaFile: './openapi.yaml', - apiFile: './src/store/emptyApi.ts', + apiFile: './src/store/empty.api.ts', apiImport: 'emptySplitApi', hooks: true, outputFiles: { diff --git a/package.json b/package.json index d9c6ac9..4ec5d73 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,11 @@ "react-dom": "^18.2.0", "react-feather": "^2.0.10", "react-i18next": "^14.1.1", + "react-qr-code": "^2.0.15", "react-redux": "^9.1.2", "react-router-dom": "^6.23.0", "redux": "^5.0.1", + "redux-logger": "^3.0.6", "redux-toolkit": "^1.1.2", "tailwindcss": "^3.4.3", "theme-change": "^2.5.0" @@ -41,15 +43,18 @@ "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", "@commitlint/format": "^19.3.0", + "@electron-toolkit/preload": "^3.0.1", "@eslint/eslintrc": "^3.0.2", "@rtk-query/codegen-openapi": "^1.2.0", "@types/i18next-browser-languagedetector": "^3.0.0", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@types/redux-logger": "^3.0.13", "@typescript-eslint/eslint-plugin": "^7.7.1", "@typescript-eslint/parser": "^7.7.1", "@vitejs/plugin-react": "^4.2.1", "daisyui": "^4.10.5", + "electron-log": "^5.1.7", "esbuild-runner": "^2.2.2", "eslint": "8", "eslint-config-prettier": "^9.1.0", diff --git a/src/app.tsx b/src/app.tsx index 35f0f42..8ebadc0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,7 +1,7 @@ import { JSX } from 'react'; import { RouterProvider } from 'react-router-dom'; import { router } from './router.tsx'; -import { FloatingConfig } from './components/floating-config.tsx'; +import { Notification } from './components/notification.tsx'; /** * The main application component. @@ -9,8 +9,8 @@ import { FloatingConfig } from './components/floating-config.tsx'; export function App(): JSX.Element { return (
- +
); } diff --git a/src/components/config.qr-code.tsx b/src/components/config.qr-code.tsx new file mode 100644 index 0000000..0307dd7 --- /dev/null +++ b/src/components/config.qr-code.tsx @@ -0,0 +1,16 @@ +import { urlConfigSelector, useAppSelector, useFetchConfigUrl } from '../store'; +import { Loading } from 'react-daisyui'; +import QRCode from 'react-qr-code'; +import { useEffect } from 'react'; + +export default function ConfigQrCode() { + const getUrl = useFetchConfigUrl(); + const url = useAppSelector(urlConfigSelector); + useEffect(() => getUrl(), [getUrl]); + return ( +
+ {!url && } + {url && } +
+ ); +} diff --git a/src/components/floating-config.tsx b/src/components/floating-config.tsx index 4d39694..17972f2 100644 --- a/src/components/floating-config.tsx +++ b/src/components/floating-config.tsx @@ -1,7 +1,8 @@ import { Button, Divider, Dropdown } from 'react-daisyui'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Menu } from 'react-feather'; +import { BarChart2, Globe, Menu, Settings } from 'react-feather'; +import { Link } from 'react-router-dom'; interface ThemeButtonProps { themeName: 'valantine' | 'light' | 'dark'; @@ -14,6 +15,7 @@ function ThemeButton({ themeName }: ThemeButtonProps) { data-set-theme={'lynx-' + themeName} data-act-class={'lynx-' + themeName} > + {t('config.' + themeName)} ); @@ -38,7 +40,7 @@ export function FloatingConfig() { - + @@ -50,14 +52,23 @@ export function FloatingConfig() { - + - + - Config + + + Config + diff --git a/src/components/notification.tsx b/src/components/notification.tsx new file mode 100644 index 0000000..fa03e44 --- /dev/null +++ b/src/components/notification.tsx @@ -0,0 +1,39 @@ +import { + removeNotification, + selectNotifications, + useAppDispatch, + useAppSelector, +} from '../store'; +import { Alert, Button, Toast } from 'react-daisyui'; +import { useCallback } from 'react'; +import { X } from 'react-feather'; + +export function Notification() { + const notifications = useAppSelector(selectNotifications); + const dispatch = useAppDispatch(); + const remove = useCallback( + (msg: string) => () => { + dispatch(removeNotification(msg)); + }, + [dispatch] + ); + return ( +
+ {notifications.map((message, index) => ( + + + + {message} + + + ))} +
+ ); +} diff --git a/src/components/theme-wrapper.tsx b/src/components/theme-wrapper.tsx index 5ff81ef..33113a2 100644 --- a/src/components/theme-wrapper.tsx +++ b/src/components/theme-wrapper.tsx @@ -3,7 +3,7 @@ import { themeChange } from 'theme-change'; export function ThemeWrapper({ children }: PropsWithChildren) { useEffect(() => { - themeChange(true); + themeChange(false); }, []); return children; diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..0694649 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,8 @@ +export async function setupLogging() { + const log = await import('electron-log/renderer'); + console.log = log.log; + console.debug = log.debug; + console.error = log.error; + console.warn = log.warn; + console.trace = log.verbose; +} diff --git a/src/main.tsx b/src/main.tsx index c1f0dc2..edab48d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,14 +1,15 @@ +import { setupLogging } from './logging'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './app.tsx'; import * as Sentry from '@sentry/react'; import { I18nextProvider } from 'react-i18next'; import i18n from './i18n'; - import './index.scss'; import { Provider } from 'react-redux'; import { store } from './store'; import { ThemeWrapper } from './components/theme-wrapper.tsx'; +import { isElectron } from './shared/constants.ts'; Sentry.init({ dsn: 'https://9fd06d22381ef360013d83b6b0c8375e@o4507214219313152.ingest.de.sentry.io/4507214225801296', @@ -25,6 +26,10 @@ Sentry.init({ replaysOnErrorSampleRate: 1.0, }); +if (isElectron) { + setupLogging(); +} + const rootElement = document.getElementById('root') as HTMLElement; const root = ReactDOM.createRoot(rootElement); root.render( diff --git a/src/router.tsx b/src/router.tsx index 2a3cbd6..a76e530 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,28 +1,39 @@ import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom'; +import { FloatingConfig } from './components/floating-config.tsx'; export const router = createBrowserRouter([ { - path: '/scans', element: ( -
+
+
), children: [ { - path: 'add', - lazy: () => import('./screens/scan.screen'), + path: '/scans', + element: ( +
+ +
+ ), + children: [ + { + path: 'add', + lazy: () => import('./screens/scan.screen'), + }, + { + path: '', + lazy: () => import('./screens/scan-list.screen'), + }, + ], }, { - path: '', - lazy: () => import('./screens/scan-list.screen'), + path: '/config', + lazy: () => import('./screens/app-config.screen'), }, ], }, - { - path: '/config', - lazy: () => import('./screens/app-config.screen'), - }, { path: '*', Component: () => , diff --git a/src/screens/app-config.screen.tsx b/src/screens/app-config.screen.tsx index a21e9d6..6382f75 100644 --- a/src/screens/app-config.screen.tsx +++ b/src/screens/app-config.screen.tsx @@ -1,7 +1,11 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { lazy, useCallback, useEffect } from 'react'; import { t } from 'i18next'; import { Button, Card } from 'react-daisyui'; import { useNavigate } from 'react-router-dom'; +import { isElectron } from '../shared/constants.ts'; +import { ArrowRight } from 'react-feather'; + +const ConfigQrCode = lazy(() => import('../components/config.qr-code')); const configKey = 'lynx:config'; @@ -17,6 +21,7 @@ export const Component: React.FC = () => { const scanConfigAndPersist = useCallback(() => { console.log('scanConfigAndPersist'); }, []); + const toScans = useCallback(() => navigate('/scans'), [navigate]); useEffect(() => { const config = checkConfig(); @@ -27,12 +32,19 @@ export const Component: React.FC = () => { return (
+ {isElectron && } {t('config.page')}

{t('config.description')}

- + )} +
diff --git a/src/screens/scan-list.screen.tsx b/src/screens/scan-list.screen.tsx index 1b6d684..818e4d6 100644 --- a/src/screens/scan-list.screen.tsx +++ b/src/screens/scan-list.screen.tsx @@ -4,15 +4,10 @@ import { Button, Loading } from 'react-daisyui'; import { ScanListDump } from '../components/scan-list.dump.tsx'; import { Plus } from 'react-feather'; import { useNavigate } from 'react-router-dom'; -import { ErrorDump } from '../components/error.dump.tsx'; export const Component: React.FC = () => { const [page, setPage] = useState(0); - const { - data: scans, - error, - isLoading, - } = useGetScansQuery({ page: page, size: 10 }); + const { data: scans, isLoading } = useGetScansQuery({ page: page, size: 10 }); const navigate = useNavigate(); @@ -31,8 +26,6 @@ export const Component: React.FC = () => { {isLoading && } - {error && } - {scans && ( { + dispatch(fetchConfigUrl()); + }, [dispatch]); +} diff --git a/src/store/index.ts b/src/store/index.ts index cf846ff..ddae117 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,18 +1,6 @@ -import { setupListeners } from '@reduxjs/toolkit/query'; -import { configureStore } from '@reduxjs/toolkit'; -import { emptySplitApi } from './emptyApi.ts'; - -export const store = configureStore({ - reducer: { - // Add the generated reducer as a specific top-level slice - [emptySplitApi.reducerPath]: emptySplitApi.reducer, - }, - // Adding the api middleware enables caching, invalidation, polling, - // and other useful features of `rtk-query`. - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(emptySplitApi.middleware), -}); - -// optional, but required for refetchOnFocus/refetchOnReconnect behaviors -// see `setupListeners` docs - takes an optional callback as the 2nd arg for customization -setupListeners(store.dispatch); +export * from './empty.api'; +export * from './hooks'; +export * from './store'; +export * from './slices'; +export * from './types'; +export * from './thunks'; diff --git a/src/store/middlewares.ts b/src/store/middlewares.ts new file mode 100644 index 0000000..81f956d --- /dev/null +++ b/src/store/middlewares.ts @@ -0,0 +1,20 @@ +import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit'; +import { isRejectedWithValue } from '@reduxjs/toolkit'; +import { addNotification } from './slices'; +import { AppDispatch } from './types.ts'; + +export const rtkQueryErrorLogger: Middleware = + ({ dispatch }: MiddlewareAPI) => + (next) => + (action) => { + // RTK Query uses `createAsyncThunk` from redux-toolkit under the hood, so we're able to utilize these matchers! + if (isRejectedWithValue(action)) { + const message = + 'data' in action.error + ? (action.error.data as { message: string }).message + : action.error.message; + if (message) dispatch(addNotification(message)); + } + + return next(action); + }; diff --git a/src/store/slices/config.slice.ts b/src/store/slices/config.slice.ts new file mode 100644 index 0000000..002cb2d --- /dev/null +++ b/src/store/slices/config.slice.ts @@ -0,0 +1,35 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { fetchConfigUrl } from '../thunks'; + +export interface ConfigState { + url?: string; +} + +const initialState = { url: undefined } satisfies ConfigState as ConfigState; + +const configSlice = createSlice({ + name: 'config', + initialState, + reducers: { + clear(state) { + delete state.url; + }, + url(state, action: PayloadAction) { + state.url = action.payload; + }, + }, + extraReducers: (builder) => { + // Add reducers for additional action types here, and handle loading state as needed + builder.addCase(fetchConfigUrl.fulfilled, (state, action) => { + // Add user to the state array + state.url = action.payload; + }); + }, +}); + +export const { clear: clearConfig, url: setUrlConfig } = configSlice.actions; + +export const reducerConfig = configSlice.reducer; + +export const urlConfigSelector = ({ config }: { config: ConfigState }) => + JSON.stringify(config); diff --git a/src/store/slices/index.ts b/src/store/slices/index.ts new file mode 100644 index 0000000..40fb1e3 --- /dev/null +++ b/src/store/slices/index.ts @@ -0,0 +1,2 @@ +export * from './config.slice'; +export * from './notification.slice'; diff --git a/src/store/slices/notification.slice.ts b/src/store/slices/notification.slice.ts new file mode 100644 index 0000000..22ac007 --- /dev/null +++ b/src/store/slices/notification.slice.ts @@ -0,0 +1,47 @@ +import { fetchConfigUrl } from '../thunks'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export interface NotificationState { + messages: string[]; +} + +const initialState = { + messages: [], +} satisfies NotificationState as NotificationState; + +const notificationSlice = createSlice({ + name: 'notification', + initialState, + reducers: { + clear(state) { + state.messages = []; + }, + remove(state, action: PayloadAction) { + state.messages = state.messages.filter((p) => p !== action.payload); + }, + add(state, action: PayloadAction) { + state.messages.push(action.payload); + }, + }, + extraReducers: (builder) => { + builder.addCase(fetchConfigUrl.rejected, (state, action) => { + state.messages.push( + action.error?.message ?? JSON.stringify(action.error) + ); + }); + }, +}); + +export const { + clear: clearNotifications, + remove: removeNotification, + add: addNotification, +} = notificationSlice.actions; + +export const reducerNotification = notificationSlice.reducer; + +export const selectNotifications = ({ + notification, +}: { + notification: NotificationState; +}) => notification.messages.filter((p) => !!p).map((p) => p!); diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..f82eeb1 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,27 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { reducerConfig, reducerNotification } from './slices'; +import { emptySplitApi } from './empty.api'; +import logger from 'redux-logger'; +import { setupListeners } from '@reduxjs/toolkit/query'; +import { rtkQueryErrorLogger } from './middlewares.ts'; + +export const store = configureStore({ + reducer: { + notification: reducerNotification, + config: reducerConfig, + // Add the generated reducer as a specific top-level slice + [emptySplitApi.reducerPath]: emptySplitApi.reducer, + }, + // Adding the api middleware enables caching, invalidation, polling, + // and other useful features of `rtk-query`. + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat( + emptySplitApi.middleware, + logger, + rtkQueryErrorLogger + ), +}); + +// optional, but required for refetchOnFocus/refetchOnReconnect behaviors +// see `setupListeners` docs - takes an optional callback as the 2nd arg for customization +setupListeners(store.dispatch); diff --git a/src/store/thunks/fetch.config-url.ts b/src/store/thunks/fetch.config-url.ts new file mode 100644 index 0000000..f481b8e --- /dev/null +++ b/src/store/thunks/fetch.config-url.ts @@ -0,0 +1,6 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; + +export const fetchConfigUrl = createAsyncThunk('config/fetchUrl', async () => { + const rest: string = await window.electron.ipcRenderer.invoke('server-url'); + return rest; +}); diff --git a/src/store/thunks/index.ts b/src/store/thunks/index.ts new file mode 100644 index 0000000..9041023 --- /dev/null +++ b/src/store/thunks/index.ts @@ -0,0 +1 @@ +export * from './fetch.config-url'; diff --git a/src/store/types.ts b/src/store/types.ts new file mode 100644 index 0000000..92505f1 --- /dev/null +++ b/src/store/types.ts @@ -0,0 +1,16 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { store } from './store'; +import { ElectronAPI } from '@electron-toolkit/preload'; + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); + +declare global { + interface Window { + electron: ElectronAPI; + api: unknown; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..2019e70 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_ELECTRON: string; + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/yarn.lock b/yarn.lock index 86c3595..064361c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1368,6 +1368,11 @@ resolved "https://registry.yarnpkg.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz#df79b7ea62c55094dc129880387864cdf41eca7c" integrity sha512-ZKXyJeFAzcpKM2kk8ipoGIPUqx9BX52omTGnfwjJvxOCaZTM2wtDK7zN0aIgPRbT9XYAlha0HtmZ+XKteuh0Gw== +"@electron-toolkit/preload@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@electron-toolkit/preload/-/preload-3.0.1.tgz#8bae193fd851f3d38c56eec13a5dd602744e8064" + integrity sha512-EzoQmpK8jqqU8YnM5jRe0GJjGVJPke2KtANqz8QtN2JPT96ViOvProBdK5C6riCm0j1T8jjAGVQCZLQy9OVoIA== + "@esbuild/aix-ppc64@0.20.2": version "0.20.2" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" @@ -1990,6 +1995,13 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/redux-logger@^3.0.13": + version "3.0.13" + resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.13.tgz#473e98428cdcc6dc93c908de66732bf932e36bc8" + integrity sha512-jylqZXQfMxahkuPcO8J12AKSSCQngdEWQrw7UiLUJzMBcv1r4Qg77P6mjGLjM27e5gFQDPD8vwUMJ9AyVxFSsg== + dependencies: + redux "^5.0.0" + "@types/semver@^7.3.12", "@types/semver@^7.5.8": version "7.5.8" resolved "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz" @@ -3087,6 +3099,11 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +deep-diff@^0.3.5: + version "0.3.8" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" + integrity sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug== + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" @@ -3172,6 +3189,11 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +electron-log@^5.1.7: + version "5.1.7" + resolved "https://registry.yarnpkg.com/electron-log/-/electron-log-5.1.7.tgz#73c7ddc1602b3a9ee355bc09d1dc490864add0eb" + integrity sha512-/PjrS9zGkrZCDTHt6IgNE3FeciBbi4wd7U76NG9jAoNXF99E9IJdvBkqvaUJ1NjLojYDKs0kTvn9YhKy1/Zi+Q== + electron-to-chromium@^1.4.668: version "1.4.746" resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.746.tgz" @@ -5857,6 +5879,11 @@ puppeteer@>=8.0.0: devtools-protocol "0.0.1273771" puppeteer-core "22.8.0" +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" @@ -5905,6 +5932,14 @@ react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-qr-code@^2.0.15: + version "2.0.15" + resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.15.tgz#fbfc12952c504bcd64275647e9d1ea63251742ce" + integrity sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw== + dependencies: + prop-types "^15.8.1" + qr.js "0.0.0" + react-redux@^9.1.2: version "9.1.2" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b" @@ -5954,6 +5989,13 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +redux-logger@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" + integrity sha512-JoCIok7bg/XpqA1JqCqXFypuqBbQzGQySrhFzewB7ThcnysTO30l4VCst86AuB9T9tuT03MAA56Jw2PNhRSNCg== + dependencies: + deep-diff "^0.3.5" + redux-thunk@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" @@ -5969,7 +6011,7 @@ redux-toolkit@^1.1.2: invariant "^2.1.1" lodash "^3.10.1" -redux@^5.0.1: +redux@^5.0.0, redux@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==