diff --git a/components/shared/workCard/WorkCardImage.tsx b/components/shared/workCard/WorkCardImage.tsx
index 833c2ddb..be663e7b 100644
--- a/components/shared/workCard/WorkCardImage.tsx
+++ b/components/shared/workCard/WorkCardImage.tsx
@@ -36,7 +36,7 @@ export const WorkCardImage: FC
= ({ src, lowResSrc, alt }) => {
return (
- {!imageError && (
+ {!imageError && src ? (
= ({ src, lowResSrc, alt }) => {
tiltReverse={true}
className={"relative m-auto"}
style={{ paddingTop, width: `min(100%,${imageWidthByContainerHeight}px)` }}>
- {
- // get the intrinsic dimensions of the image
- const { naturalWidth, naturalHeight } = target as HTMLImageElement
- setImageHeight(naturalHeight)
- setImageWidth(naturalWidth)
- }}
- />
- {
- setImageLoaded(true)
- }}
- onError={() => {
- setImageError(true)
- }}
- />
+ {lowResSrc && (
+ {
+ // get the intrinsic dimensions of the image
+ const { naturalWidth, naturalHeight } = target as HTMLImageElement
+ setImageHeight(naturalHeight)
+ setImageWidth(naturalWidth)
+ }}
+ />
+ )}
+ {src && (
+ {
+ setImageLoaded(true)
+ }}
+ onError={() => {
+ setImageError(true)
+ }}
+ />
+ )}
- )}
-
- {imageError && (
+ ) : (
{
const value = encodeURIComponent(params[key])
@@ -16,19 +17,40 @@ function buildRoute(route: string, params?: RouteParams, query?: QueryParams): s
}, route)
}
+ const queryParams = new URLSearchParams()
if (query) {
- const queryString = Object.keys(query)
- .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(query[key])}`)
- .join("&")
- route += `?${queryString}`
+ Object.keys(query).forEach(key => {
+ queryParams.append(key, query[key].toString())
+ })
+ }
+
+ if (queryParams.toString()) {
+ return `${route}?${queryParams}`
}
return route
}
-// Add url resolvers for each route below
-const resolveWorkUrl = (id: string) => {
- return buildRoute("/work/:id", { id: id })
-}
+type ResolveUrlOptions =
+ | {
+ type: "work"
+ routeParams?: { id: number | string }
+ queryParams?: QueryParams
+ }
+ | {
+ type: "search"
+ routeParams?: undefined
+ queryParams?: QueryParams
+ }
-export { resolveWorkUrl, buildRoute }
+export const resolveUrl = ({ type, routeParams, queryParams }: ResolveUrlOptions) => {
+ switch (type as ResolveUrlOptions["type"]) {
+ case "work":
+ if (!routeParams?.id) return ""
+ return buildRoute({ route: "/work/:id", params: { id: routeParams.id }, query: queryParams })
+ case "search":
+ return buildRoute({ route: "/search", query: queryParams })
+ default:
+ return ""
+ }
+}
diff --git a/lib/machines/search/queries.ts b/lib/machines/search/queries.ts
new file mode 100644
index 00000000..ab608def
--- /dev/null
+++ b/lib/machines/search/queries.ts
@@ -0,0 +1,52 @@
+import { QueryClient } from "@tanstack/react-query"
+import { fromPromise } from "xstate"
+
+import { getFacetMachineNames } from "@/components/shared/searchFilters/helper"
+import {
+ SearchFacetsQuery,
+ SearchWithPaginationQuery,
+ useSearchFacetsQuery,
+ useSearchWithPaginationQuery,
+} from "@/lib/graphql/generated/fbi/graphql"
+
+import { TFilters } from "./types"
+
+export const performSearch = fromPromise(
+ ({
+ input: { q, filters, offset, limit, queryClient },
+ }: {
+ input: { q: string; offset: number; limit: number; filters: TFilters; queryClient: QueryClient }
+ }): Promise => {
+ const args = {
+ q: { all: q },
+ offset: offset,
+ limit,
+ filters,
+ }
+
+ return queryClient.fetchQuery({
+ queryKey: useSearchWithPaginationQuery.getKey(args),
+ queryFn: useSearchWithPaginationQuery.fetcher(args),
+ })
+ }
+)
+
+export const getFacets = fromPromise(
+ ({
+ input: { q, queryClient, filters, facetLimit },
+ }: {
+ input: { q: string; facetLimit: number; filters: TFilters; queryClient: QueryClient }
+ }): Promise => {
+ const args = {
+ q: { all: q },
+ facets: getFacetMachineNames(),
+ facetLimit,
+ filters,
+ }
+
+ return queryClient.fetchQuery({
+ queryKey: useSearchFacetsQuery.getKey(args),
+ queryFn: useSearchFacetsQuery.fetcher(args),
+ })
+ }
+)
diff --git a/lib/machines/search/search.machine.setup.ts b/lib/machines/search/search.machine.setup.ts
new file mode 100644
index 00000000..a67cdd8a
--- /dev/null
+++ b/lib/machines/search/search.machine.setup.ts
@@ -0,0 +1,117 @@
+import { assign, emit, setup } from "xstate"
+
+import { getFacets, performSearch } from "./queries"
+import { TContext, TFilters, TInput } from "./types"
+
+export default setup({
+ types: {
+ context: {} as TContext,
+ input: {} as TInput,
+ },
+ actions: {
+ toggleFilterInContext: assign({
+ selectedFilters: ({ event: { name, value }, context: { selectedFilters } }) => {
+ const filterName = name as keyof TFilters
+ if (!selectedFilters) {
+ selectedFilters = {}
+ }
+
+ if (!selectedFilters.hasOwnProperty(filterName)) {
+ selectedFilters[filterName] = []
+ }
+ // Remove filter.
+ if (selectedFilters[filterName] && selectedFilters[filterName].includes(value)) {
+ return {
+ ...selectedFilters,
+ [name]: selectedFilters[filterName].filter(filterValue => filterValue !== value),
+ }
+ }
+ // Add filter.
+ return {
+ ...selectedFilters,
+ [name]: [...(selectedFilters[filterName] ?? []), value],
+ }
+ },
+ }),
+ emitFilterToggled: emit(({ event }) => ({
+ type: "filterToggled",
+ toggled: event,
+ })),
+ emitQDeleted: emit(() => ({
+ type: "qDeleted",
+ })),
+ setCurrentQueryInContext: assign({
+ currentQuery: ({ event }) => event.q,
+ }),
+ setSbmittedQueryInContext: assign({
+ submittedQuery: ({ context }) => (context.submittedQuery = context.currentQuery),
+ }),
+ resetFilters: assign(() => ({
+ selectedFilters: {},
+ })),
+ resetQuery: assign(() => ({
+ currentQuery: undefined,
+ submittedQuery: undefined,
+ })),
+ resetSearchData: assign(() => ({
+ searchData: undefined,
+ })),
+ setFacetDataInContext: assign({
+ facetData: ({
+ event: {
+ output: {
+ search: { facets },
+ },
+ },
+ }) => facets,
+ }),
+ setSearchDataInContext: assign({
+ searchData: ({
+ event: {
+ output: { search },
+ },
+ context: { searchData },
+ }) => {
+ return {
+ hitcount: search.hitcount,
+ pages: [...(searchData?.pages ?? []), [...search.works]],
+ }
+ },
+ }),
+ setQueryClientInContext: assign({
+ queryClient: ({ event }) => event.queryClient,
+ }),
+ setInitialFiltersInContext: assign({
+ selectedFilters: ({ event }) => event.filters,
+ }),
+ setLoadMoreValuesInContext: assign({
+ searchOffset: ({ context: { searchOffset, searchPageSize, searchData } }) => {
+ if (!searchData) {
+ return searchOffset
+ }
+ return searchOffset + searchPageSize
+ },
+ }),
+ },
+ actors: {
+ performSearch,
+ getFacets,
+ },
+ guards: {
+ eventHasSearchString: ({ event }) => {
+ return Boolean(event.q && event.q.length > 0)
+ },
+ contextHasSearchString: ({ context }) => {
+ return Boolean(context.currentQuery && context.currentQuery.length > 0)
+ },
+ contextHasQueryClient: ({ context }) => {
+ return Boolean(context.queryClient)
+ },
+ maxLimitReached: ({ context: { searchPageSize, searchData } }) => {
+ if (!searchData) {
+ return false
+ }
+ return searchData.pages.length >= Math.ceil(searchData.hitcount / searchPageSize)
+ },
+ },
+})
diff --git a/lib/machines/search/search.machine.ts b/lib/machines/search/search.machine.ts
new file mode 100644
index 00000000..dc153204
--- /dev/null
+++ b/lib/machines/search/search.machine.ts
@@ -0,0 +1,180 @@
+import { and, not } from "xstate"
+
+import searchMachineSetup from "./search.machine.setup"
+
+export default searchMachineSetup.createMachine({
+ /** @xstate-layout N4IgpgJg5mDOIC5SzAQwE4GMAWA6ARgPaEAusJ6qADgMQDKAogCoD6AigKoMBKAmiwGEAMgEkGAOSYBtAAwBdRKCqFYASxKrCAO0UgAHogC0ANgCMAVlwAWAJzGATDIAcNmeYDM9zwBoQAT0QLAHZcYycnIKdTSNcndysAXwTfFAwcAmIyCmp6ZhZGAEFuAQAJfKZuEXEAcVkFJBBlNQ1tXQMEQytjS0cbKyCg0xdwpytzXwCEd2jcGxt3d1Gne273AaSUtCw8IlJySlpGViqRJhECoRYAMREhJh46Ot0m9U0dBvaTR1Cw0ysFpzmLxuCaIKxDXCmdw2cwyFamYyLKIbECpbYZPbZWgAIQA8rimHQKgUAAosAAiuPEDCeDReLXeoE+5iiuCC0Js9isK36phhoIQphkNlC4Ui0RczniphRaPSuyyBxoeIJRO4pIpVJppnqShUr1aHyMLNMbI5XJ5g35-kQZlFESiMSl4NlW3SqggABswDQmLjqtUhAxrrd7txaXrmm82mD7CL2V4FjZwt0rFYBfZ7E5cB5hXzPOYhdN3K60ngPd7ff7A8GbnceFIdc99QyYwh7EERV4+stnEEgd0BYXLO5jEEuUEwp2zMZS+iKz6mLwSVVavJm1HDUzjdzZn84-NoYWxwLO1ZIcCZHCx+ZwbPkqi3eWvYvl6vG7rGi3o0bBUEZKEfwAu4t6eAi7gClC56jOYnaDCBQRpqOc7ui+uRFKUEZfpujL6IgizuDmrgODInj2KYfJxAKiIisefQyIMTg0TKD5ys+laFMUJQfhuBq4e0awhPuNgIrBhYxMYAqxLgEQwuyLieCBLGbGWuALjQQi4gU5IsAAsri3A0uudLflueGCiJuBxrY8SZlYV6FvYkF8iKTErOyQxeHBKF4AAZqonokGA6CqFoUAFFoEB0E+oVQDQEDaGAuDkKgQXJU+uD+YFwWxRFUUxWFWH0j+24dHyAHmJVslMW4fwyOmNoILeuBuOOYyAlyDhdD5mUBUFIVhXl0VlrF6WqWxsXxYlalaAAboQADWSVsb12UDeFkXDdso0rRNYUIKF82YKlbx1EVpn8YEbjGLMdjCms4yNVy2acrBUT2OYPVZf1uWbQVUBjeie1xcF6CEOguBUJ6qW+eDAC2gPpN9OWDX9I1hYjeDAwdc2EMdDJncZkZ8W2owhIx7L9OC5HCpBfSzHC8QPV9fUoxt+XowDyMQ9zk0JVoSWHYty0ZbzqMc9tGPc6tP37UL+OnfI504W2QorNYH3ih9Q7-jJxEfSza2-RLOCjdLYsg+gYMQ1DMPw5jMts0N-2OzzrPrTjR0ndohOfsVZntGT1h1Q4j2TMeMljkM-Y9Z6hCoBAsW6eDYBbTg3BwAAroFsCY3z01C0tDtxwnScp2n2AZ7A2dkHncu4wrPtK0T2Ek7+hh8jdXT1cY-TmIe5FOFJ3wfbeLlhB9Yyx-Hidhcn6Cp0+Vc17nwM0KD4OQ9DJCw+gCMrSXs9QPPi9lsvOd11Ant497Wi+7xrbt6YWYXu9DEdh4aZh4EiZWV05F-DiH8CwSQHxaEIBAOAug2IPxKuZQwgJCIRA7KOScvJrSTA7lyM0sEiwyDCB4FwPUFT7GoLAgORgnBuBkuOdkY5e5Wm-h0NYVkEL1W5OYUOMJEisQyguchl0yr2BkoCYwM4xhjjiGEai+DcBrDiMOVw9k+iG1luzCusUBFtnZCEYEXQIhML5AzRMlF+xpgoveFS6ILbO05pjLRv5IiWA8MOUOkEGLWEZvIqc443CqKdmjSWANdr-QcaVOIhEXEWDcU9fonivD-Bjrw1SNjAmmwxitfmYAwnmWfpEM0QDog3nFBmYwIpugJOZsk6x7tjYaKlu7HJ7QnGsJ7gYgU3IRSoISbCOM+DlKPhSbU8W9Subu1dpokyKtfwRNafo-sQ4ylyKjgY-x61bFBNdrgLJTSf72RzMMRC7k3qmCHJZMwFNPrVPSIfMuC8K7nzILsjotgRRQn7gsTM-Z4hjGcmMNk-9royD5O86epc57lyXlnC+wNnmdELJCECA8P4-MMb-TqACrBAPBFcpIQA */
+ id: "search",
+ initial: "bootstrap",
+ context: ({ input }) => ({
+ searchOffset: input.initialOffset ?? 0,
+ searchPageSize: input.searchPageSize,
+ facetLimit: input.facetLimit,
+ currentQuery: input.q ?? "",
+ submittedQuery: undefined,
+ searchData: undefined,
+ facetData: undefined,
+ selectedFilters: input.filters ?? {},
+ queryClient: input.queryClient ?? null,
+ }),
+ states: {
+ bootstrap: {
+ on: {
+ SET_QUERY_CLIENT: {
+ actions: ["setQueryClientInContext"],
+ },
+ SET_SEARCH_STRING: {
+ actions: ["setCurrentQueryInContext", "setSbmittedQueryInContext"],
+ },
+ SET_INITIAL_FILTERS: {
+ actions: ["setInitialFiltersInContext"],
+ },
+ BOOTSTRAP_DONE: [
+ {
+ guard: and(["contextHasQueryClient", "contextHasSearchString"]),
+ target: "filteringAndSearching",
+ },
+ {
+ target: "idle",
+ },
+ ],
+ },
+ },
+ idle: {
+ on: {
+ TOGGLE_FILTER: [
+ {
+ guard: "contextHasSearchString",
+ actions: ["resetSearchData", "toggleFilterInContext", "emitFilterToggled"],
+ target: "filteringAndSearching",
+ },
+ {
+ target: "idle",
+ },
+ ],
+ TYPING: [
+ {
+ guard: "eventHasSearchString",
+ actions: ["setCurrentQueryInContext"],
+ },
+ {
+ actions: ["emitQDeleted", "resetQuery"],
+ },
+ ],
+ SEARCH: [
+ {
+ guard: "contextHasSearchString",
+ actions: ["resetSearchData", "resetFilters", "setSbmittedQueryInContext"],
+ target: "filteringAndSearching",
+ },
+ {
+ actions: ["resetFilters"],
+ target: "idle",
+ },
+ ],
+ LOAD_MORE: {
+ guard: and(["contextHasSearchString", not("maxLimitReached")]),
+ actions: ["setLoadMoreValuesInContext"],
+ target: "loadingMoreSearchResults",
+ },
+ },
+ },
+ filteringAndSearching: {
+ type: "parallel",
+ initial: "search",
+ states: {
+ search: {
+ initial: "searching",
+ guard: "contextHasQueryClient",
+ states: {
+ searching: {
+ invoke: {
+ src: "performSearch",
+ input: ({ context }) => {
+ if (!context.queryClient) {
+ throw new Error("QueryClient is not set in context.")
+ }
+ return {
+ q: context.currentQuery,
+ queryClient: context.queryClient,
+ filters: context.selectedFilters,
+ offset: context.searchOffset,
+ limit: context.searchPageSize,
+ }
+ },
+ onDone: {
+ actions: ["setSearchDataInContext"],
+ target: "done",
+ },
+ onError: {},
+ },
+ },
+ done: {
+ type: "final",
+ },
+ },
+ },
+ filter: {
+ initial: "filtering",
+ states: {
+ filtering: {
+ invoke: {
+ src: "getFacets",
+ input: ({ context }) => {
+ if (!context.queryClient) {
+ throw new Error("QueryClient is not set in context.")
+ }
+ return {
+ q: context.currentQuery,
+ queryClient: context.queryClient,
+ filters: context.selectedFilters,
+ facetLimit: context.facetLimit,
+ }
+ },
+ onDone: {
+ actions: ["setFacetDataInContext"],
+ target: "done",
+ },
+ onError: {},
+ },
+ },
+ done: {
+ type: "final",
+ },
+ },
+ },
+ },
+ onDone: {
+ target: "#search.idle",
+ },
+ },
+ loadingMoreSearchResults: {
+ initial: "searching",
+ guard: "contextHasQueryClient",
+ states: {
+ searching: {
+ invoke: {
+ src: "performSearch",
+ input: ({ context }) => {
+ if (!context.queryClient) {
+ throw new Error("QueryClient is not set in context.")
+ }
+ return {
+ q: context.currentQuery,
+ queryClient: context.queryClient,
+ filters: context.selectedFilters,
+ offset: context.searchOffset,
+ limit: context.searchPageSize,
+ }
+ },
+ onDone: {
+ actions: ["setSearchDataInContext"],
+ target: "#search.idle",
+ },
+ onError: {},
+ },
+ },
+ },
+ },
+ },
+})
diff --git a/lib/machines/search/types.ts b/lib/machines/search/types.ts
new file mode 100644
index 00000000..06d4845a
--- /dev/null
+++ b/lib/machines/search/types.ts
@@ -0,0 +1,33 @@
+import { QueryClient } from "@tanstack/react-query"
+
+import {
+ SearchFacetsQuery,
+ SearchFiltersInput,
+ SearchWithPaginationQuery,
+} from "@/lib/graphql/generated/fbi/graphql"
+
+export type TFilters = Omit
+
+export type TContext = {
+ facetLimit: number
+ searchOffset: number
+ searchPageSize: number
+ currentQuery: string
+ submittedQuery?: string
+ searchData?: {
+ hitcount: SearchWithPaginationQuery["search"]["hitcount"]
+ pages: SearchWithPaginationQuery["search"]["works"][]
+ }
+ facetData?: SearchFacetsQuery["search"]["facets"]
+ selectedFilters: TFilters
+ queryClient: QueryClient | null
+}
+
+export type TInput = {
+ q?: string
+ filters?: TFilters
+ queryClient?: QueryClient
+ initialOffset: number
+ searchPageSize: number
+ facetLimit: number
+}
diff --git a/lib/machines/search/useSearchMachineActor.tsx b/lib/machines/search/useSearchMachineActor.tsx
new file mode 100644
index 00000000..360d5dfc
--- /dev/null
+++ b/lib/machines/search/useSearchMachineActor.tsx
@@ -0,0 +1,107 @@
+"use client"
+
+import { useQueryClient } from "@tanstack/react-query"
+import _ from "lodash"
+import { ReadonlyURLSearchParams } from "next/navigation"
+import { useSearchParams } from "next/navigation"
+import { useEffect, useRef, useState } from "react"
+import { AnyEventObject, createActor } from "xstate"
+
+import { getFacetsForSearchRequest } from "@/components/pages/searchPageLayout/helper"
+import goConfig from "@/lib/config/config"
+
+import searchMachine from "./search.machine"
+
+const searchActor = createActor(searchMachine, {
+ input: {
+ initialOffset: goConfig("search.offset.initial"),
+ searchPageSize: goConfig("search.item.limit"),
+ facetLimit: goConfig("search.facet.limit"),
+ },
+}).start()
+
+// Administer search query params when filters are toggled.
+searchActor.on("filterToggled", (emittedEvent: AnyEventObject) => {
+ const url = new URL(window.location.href)
+ const {
+ toggled: { name: filterName, value: filterValue },
+ } = emittedEvent
+
+ if (url.searchParams.has(filterName, filterValue)) {
+ url.searchParams.delete(filterName, filterValue)
+ } else {
+ url.searchParams.append(filterName, filterValue)
+ }
+
+ window.history.pushState({}, "", url.href)
+})
+
+// Make sure the search query is removed from the URL when the search is cleared.
+// And the same goes for facets.
+searchActor.on("qDeleted", () => {
+ let urlShouldBeUpdated = false
+
+ const url = new URL(window.location.href)
+ if (url.searchParams.get("q")) {
+ url.searchParams.delete("q")
+ urlShouldBeUpdated = true
+ }
+
+ const facets = getFacetsForSearchRequest(url.searchParams as ReadonlyURLSearchParams)
+ for (const filter in facets) {
+ url.searchParams.delete(filter)
+ urlShouldBeUpdated = true
+ }
+
+ if (urlShouldBeUpdated) {
+ window.history.pushState({}, "", url.href)
+ }
+})
+
+/**
+ *
+ * This hook is referencing the searchActor from the search.machine.ts file.
+ *
+ * The reason why we are using a ref is because we want to keep the same actor instance.
+ *
+ * The bootstrap state is used to set the initial filters and search string from the URL.
+ *
+ * Furthermore the queryClient is set to the actor context
+ * so we are able to use it in queries inside the machine.
+ */
+const useSearchMachineActor = () => {
+ const searchParams = useSearchParams()
+ const queryClient = useQueryClient()
+ const [isBootstrapped, setIsBootstrapped] = useState(false)
+ const actorRef = useRef(searchActor)
+ const actor = actorRef.current
+
+ useEffect(() => {
+ if (!actor.getSnapshot().matches("bootstrap") || isBootstrapped) {
+ return
+ }
+
+ const q = searchParams.get("q")
+ const filters = getFacetsForSearchRequest(searchParams as ReadonlyURLSearchParams)
+
+ if (!_.isEmpty(filters)) {
+ actor.send({ type: "SET_INITIAL_FILTERS", filters })
+ }
+ if (q) {
+ actor.send({ type: "SET_SEARCH_STRING", q })
+ }
+
+ actor.send({ type: "SET_QUERY_CLIENT", queryClient })
+
+ actor.send({ type: "BOOTSTRAP_DONE" })
+
+ setIsBootstrapped(true)
+ // We choose to ignore the eslint warning below
+ // because we want to make sure it only reruns if isBootstrapped changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isBootstrapped])
+
+ return actor
+}
+
+export default useSearchMachineActor
diff --git a/next.config.mjs b/next.config.mjs
index 0adf92b6..f4019068 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,5 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
+ experimental: {
+ serverSourceMaps: true,
+ },
images: {
remotePatterns: [
{
diff --git a/package.json b/package.json
index a525291b..7a7a99f9 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"dev:https": "next dev --experimental-https",
"build": "next build",
"start": "next start",
+ "start:with-server-source-maps": "NODE_OPTIONS='--enable-source-maps=true' next start",
"lint": "next lint",
"format:check": "prettier --check .",
"format:write": "prettier --write .",
@@ -28,6 +29,7 @@
"@redux-devtools/extension": "^3.3.0",
"@tanstack/react-query": "^5.59.0",
"@types/lodash": "^4.17.12",
+ "@xstate/react": "^4.1.3",
"@uidotdev/usehooks": "^2.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -46,6 +48,7 @@
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.0",
+ "xstate": "^5.18.2",
"zod": "^3.23.8",
"zustand": "^5.0.0-rc.2"
},
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 00000000..fe960da0
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png
new file mode 100644
index 00000000..7bba9895
Binary files /dev/null and b/public/favicon-96x96.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 00000000..44a4739a
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/site.webmanifest b/public/site.webmanifest
new file mode 100644
index 00000000..13708fa2
--- /dev/null
+++ b/public/site.webmanifest
@@ -0,0 +1,21 @@
+{
+ "name": "GO",
+ "short_name": "GO",
+ "icons": [
+ {
+ "src": "/web-app-manifest-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/web-app-manifest-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png
new file mode 100644
index 00000000..fbb857f9
Binary files /dev/null and b/public/web-app-manifest-192x192.png differ
diff --git a/public/web-app-manifest-512x512.png b/public/web-app-manifest-512x512.png
new file mode 100644
index 00000000..ffe3874b
Binary files /dev/null and b/public/web-app-manifest-512x512.png differ
diff --git a/yarn.lock b/yarn.lock
index 3da062b1..21bc878e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4213,6 +4213,14 @@
"@whatwg-node/fetch" "^0.9.22"
tslib "^2.6.3"
+"@xstate/react@^4.1.3":
+ version "4.1.3"
+ resolved "https://registry.yarnpkg.com/@xstate/react/-/react-4.1.3.tgz#d3db7102ad950584d15f5a07fc17d52a127f3c68"
+ integrity sha512-zhE+ZfrcCR87bu71Rkh5Z5ruZBivR/7uD/dkelzJqjQdI45IZc9DqTI8lL4Cg5+VN2p5k86KxDsusqW1kW11Tg==
+ dependencies:
+ use-isomorphic-layout-effect "^1.1.2"
+ use-sync-external-store "^1.2.0"
+
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz"
@@ -10890,6 +10898,11 @@ use-callback-ref@^1.3.0:
dependencies:
tslib "^2.0.0"
+use-isomorphic-layout-effect@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
+ integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
+
use-sidecar@^1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz"
@@ -10898,6 +10911,11 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
+use-sync-external-store@^1.2.0:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"
+ integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==
+
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
@@ -11247,6 +11265,11 @@ xmlchars@^2.2.0:
resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
+xstate@^5.18.2:
+ version "5.18.2"
+ resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.18.2.tgz#924122af5102f3c3f7e172ebf20a09455ddb2963"
+ integrity sha512-hab5VOe29D0agy8/7dH1lGw+7kilRQyXwpaChoMu4fe6rDP+nsHYhDYKfS2O4iXE7myA98TW6qMEudj/8NXEkA==
+
xtend@^4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz"