From 8714f6543001e2f1a0f85a6bb1d815fe3683cf65 Mon Sep 17 00:00:00 2001 From: Demian Parkhomenko <95881717+DemianParkhomenko@users.noreply.github.com> Date: Wed, 24 Jan 2024 02:07:03 +0200 Subject: [PATCH] Add Metacom (#45) PR: https://github.com/dev-KPI/messenger-frontend/pull/45 Issue: https://github.com/dev-KPI/messenger-frontend/issues/34 --- .env | 1 + .env.local.dist | 2 + next.config.js | 4 +- package-lock.json | 201 +++++++++++++++++++++++++++++++++ package.json | 3 + project-words.txt | 1 + src/app/layout.tsx | 18 +-- src/app/page.tsx | 53 +++++---- src/components/ui/toast.tsx | 126 +++++++++++++++++++++ src/components/ui/toaster.tsx | 35 ++++++ src/components/ui/use-toast.ts | 189 +++++++++++++++++++++++++++++++ src/constants/env.ts | 1 + src/constants/index.ts | 1 + src/store/providers/index.tsx | 13 ++- src/utils/api/hooks.ts | 65 +++++++++++ src/utils/api/index.ts | 2 + src/utils/api/metacom.ts | 45 ++++++++ src/utils/index.ts | 1 + 18 files changed, 726 insertions(+), 35 deletions(-) create mode 100644 .env create mode 100644 .env.local.dist create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/use-toast.ts create mode 100644 src/constants/env.ts create mode 100644 src/constants/index.ts create mode 100644 src/utils/api/hooks.ts create mode 100644 src/utils/api/index.ts create mode 100644 src/utils/api/metacom.ts diff --git a/.env b/.env new file mode 100644 index 0000000..ee3c777 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +NEXT_PUBLIC_URL_PRODUCT_API=ws://messenger-backend.fly.dev diff --git a/.env.local.dist b/.env.local.dist new file mode 100644 index 0000000..1e6764d --- /dev/null +++ b/.env.local.dist @@ -0,0 +1,2 @@ +NEXT_PUBLIC_URL_PRODUCT_API=ws://localhost:8000 +# NEXT_PUBLIC_URL_PRODUCT_API=ws://messenger-backend.fly.dev diff --git a/next.config.js b/next.config.js index 767719f..a843cbe 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + reactStrictMode: true, +} module.exports = nextConfig diff --git a/package-lock.json b/package-lock.json index 960f165..8cf8273 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.312.0", + "metacom": "^3.1.2", "next": "14.0.3", "next-themes": "^0.2.1", "react": "^18", @@ -913,6 +915,32 @@ } } }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", @@ -947,6 +975,33 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", @@ -970,6 +1025,29 @@ } } }, + "node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", @@ -1035,6 +1113,40 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz", + "integrity": "sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", @@ -1070,6 +1182,24 @@ } } }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", @@ -1122,6 +1252,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz", @@ -4570,6 +4723,34 @@ "node": ">= 8" } }, + "node_modules/metacom": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/metacom/-/metacom-3.1.2.tgz", + "integrity": "sha512-NtVfc1V/pin1oIi4suTieclOYTKIKm0Nowf/7fV6vVVy/CYuwIIvHA33eK2IvY9hWFYHxViIoj7F1myU+IU9UA==", + "dependencies": { + "metautil": "^3.15.0", + "ws": "^8.14.0" + }, + "engines": { + "node": "18 || 20" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/tshemsedinov" + } + }, + "node_modules/metautil": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/metautil/-/metautil-3.15.0.tgz", + "integrity": "sha512-kGG920X8R10X6He2VKPRQFtG45DcDCSYgZehUFoqES52Tv88VALJm6EplZGwDRK7IiBDWsWDeS8d2OcW6bVXeg==", + "engines": { + "node": "18 || 20" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/tshemsedinov" + } + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -6588,6 +6769,26 @@ "typedarray-to-buffer": "^3.1.5" } }, + "node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", diff --git a/package.json b/package.json index 3924683..05df58a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "prettier:check": "prettier --check .", "prettier:write": "prettier --write .", "typescript:check": "tsc -p tsconfig.json", + "env:local": "cp .env.local.dist .env.local", "prepare": "husky install", "cspell": "cspell --show-suggestions --show-context --gitignore ." }, @@ -20,9 +21,11 @@ "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "lucide-react": "^0.312.0", + "metacom": "^3.1.2", "next": "14.0.3", "next-themes": "^0.2.1", "react": "^18", diff --git a/project-words.txt b/project-words.txt index 6b5cd43..5697b73 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1,4 +1,5 @@ Autobuild +autofetch GOLUB tailwindcss tsbuildinfo diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8354f1..fb6f272 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,24 +1,24 @@ -import type { Metadata } from 'next' +'use client' import { text, title } from '@/app/fonts' import '@/app/globals.css' +import { Toaster } from '@/components/ui/toaster' import Providers from '@/store/providers' +import { FC, ReactNode } from 'react' -export const metadata: Metadata = { - description: 'Generated by create next app', - title: 'Create Next App', +interface PageProps { + children: ReactNode } -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { +const Layout: FC = ({ children } = { children: [] }) => { return ( {children} + ) } + +export default Layout diff --git a/src/app/page.tsx b/src/app/page.tsx index 5cb57a6..dcaf385 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,29 +1,34 @@ -import IconChange from '@/components/ui/icons/IconChange' -import Image from 'next/image' +'use client' + +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { useApi, useApiContext } from '@/utils' + +const Home = () => { + const api = useApiContext() + const condition = true + const { toast } = useToast() + + const { fetch, response: calculated } = useApi( + () => condition && api.example.add({ a: 1, b: 2 }), + { + onSuccess: () => + toast({ + title: '🚀 Two numbers added', + }), + } + ) + const { response: data } = useApi(() => condition && api.example.data(), { + autofetch: true, + }) -export default function Home() { return ( -
-
- This is Image:{' '} - change -
-
- This is an IconChange component:{' '} - -

Hover me!

-
-
GOLUB
-
🚀
+
+
{data}
+ + {calculated}
) } + +export default Home diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..43defda --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,126 @@ +import { cn } from '@/lib/utils' +import * as ToastPrimitives from '@radix-ui/react-toast' +import { type VariantProps, cva } from 'class-variance-authority' +import { X } from 'lucide-react' +import * as React from 'react' + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', + { + defaultVariants: { + variant: 'default', + }, + variants: { + variant: { + default: 'border bg-background text-foreground', + destructive: + 'destructive group border-destructive bg-destructive text-destructive-foreground', + }, + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + Toast, + ToastAction, + type ToastActionElement, + ToastClose, + ToastDescription, + type ToastProps, + ToastProvider, + ToastTitle, + ToastViewport, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..9dd0cb5 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +'use client' + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from '@/components/ui/toast' +import { useToast } from '@/components/ui/use-toast' + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ action, description, id, title, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000..e0eeded --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -0,0 +1,189 @@ +// Inspired by react-hot-toast library +import type { ToastActionElement, ToastProps } from '@/components/ui/toast' + +import * as React from 'react' + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + action?: ToastActionElement + description?: React.ReactNode + id: string + title?: React.ReactNode +} + +const actionTypes = { + ADD_TOAST: 'ADD_TOAST', + DISMISS_TOAST: 'DISMISS_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST', + UPDATE_TOAST: 'UPDATE_TOAST', +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + toast: Partial + type: ActionType['UPDATE_TOAST'] + } + | { + toast: ToasterToast + type: ActionType['ADD_TOAST'] + } + | { + toastId?: ToasterToast['id'] + type: ActionType['DISMISS_TOAST'] + } + | { + toastId?: ToasterToast['id'] + type: ActionType['REMOVE_TOAST'] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + toastId: toastId, + type: 'REMOVE_TOAST', + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case 'UPDATE_TOAST': + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case 'DISMISS_TOAST': { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case 'REMOVE_TOAST': + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + toast: { ...props, id }, + type: 'UPDATE_TOAST', + }) + const dismiss = () => dispatch({ toastId: id, type: 'DISMISS_TOAST' }) + + dispatch({ + toast: { + ...props, + id, + onOpenChange: (open) => { + if (!open) dismiss() + }, + open: true, + }, + type: 'ADD_TOAST', + }) + + return { + dismiss, + id: id, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + dismiss: (toastId?: string) => dispatch({ toastId, type: 'DISMISS_TOAST' }), + toast, + } +} + +export { toast, useToast } diff --git a/src/constants/env.ts b/src/constants/env.ts new file mode 100644 index 0000000..5a83e42 --- /dev/null +++ b/src/constants/env.ts @@ -0,0 +1 @@ +export const URL_PRODUCT_API = process.env.NEXT_PUBLIC_URL_PRODUCT_API || '' diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..94812ca --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1 @@ +export * from './env' diff --git a/src/store/providers/index.tsx b/src/store/providers/index.tsx index e72179d..0e3a0b4 100644 --- a/src/store/providers/index.tsx +++ b/src/store/providers/index.tsx @@ -1,5 +1,8 @@ 'use client' +import Content from '@/components/ui/content' +import IconGolub from '@/components/ui/icons/IconGolub' +import { ApiContext, useApiLoader } from '@/utils' import { FC, ReactNode } from 'react' import ThemeProvider from './theme-provider' @@ -9,6 +12,7 @@ type ProvidersProps = { } const Providers: FC = ({ children }) => { + const { api, isApiReady } = useApiLoader() return ( = ({ children }) => { disableTransitionOnChange enableSystem > - {children} + {isApiReady && api ? ( + {children} + ) : ( + // TODO: handle initial metacom loading state + + + + )} ) } diff --git a/src/utils/api/hooks.ts b/src/utils/api/hooks.ts new file mode 100644 index 0000000..7b3a531 --- /dev/null +++ b/src/utils/api/hooks.ts @@ -0,0 +1,65 @@ +'use client' + +import { useToast } from '@/components/ui/use-toast' +import { useCallback, useEffect, useRef, useState } from 'react' + +export const useApi = >( + fetcher: () => '' | T | false | null | undefined, + options: { + autofetch?: boolean + onBeforeFetch?: () => Promise | void + onError?: () => void + onResponse?: (res: Awaited) => Promise | void + onSuccess?: () => void + } = {} +) => { + const { toast } = useToast() + const [isLoading, setLoading] = useState(false) + const [response, setResponse] = useState(null) + + const optionsRef = useRef(options) + optionsRef.current = options + + const fetcherRef = useRef(fetcher) + fetcherRef.current = fetcher + + const refetch = useCallback(async () => { + setLoading(true) + await optionsRef.current.onBeforeFetch?.() + const res = await fetcherRef.current() + if (!res) { + setLoading(false) + return + } + await optionsRef.current.onResponse?.(res) + if (res.error) { + if (optionsRef.current.onError) { + await optionsRef.current.onError?.() + } + } else { + setResponse(res) + try { + await optionsRef.current.onSuccess?.() + } catch (e) { + toast({ + description: 'Request failed.', + title: 'Network Error', + }) + console.error(e) + } + } + setLoading(false) + }, [toast]) + + useEffect(() => { + if (optionsRef.current.autofetch) { + refetch() + } + }, [refetch]) + + return { + fetch: refetch, + isLoading, + response, + } +} diff --git a/src/utils/api/index.ts b/src/utils/api/index.ts new file mode 100644 index 0000000..41e7a5f --- /dev/null +++ b/src/utils/api/index.ts @@ -0,0 +1,2 @@ +export * from './hooks' +export * from './metacom' diff --git a/src/utils/api/metacom.ts b/src/utils/api/metacom.ts new file mode 100644 index 0000000..29e66d4 --- /dev/null +++ b/src/utils/api/metacom.ts @@ -0,0 +1,45 @@ +'use client' + +import { useToast } from '@/components/ui/use-toast' +import { URL_PRODUCT_API } from '@/constants' +import { Metacom } from 'metacom' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +const units = ['example'] +const metacom = Metacom.create(URL_PRODUCT_API) + +export const useApiLoader = () => { + const [api, setApi] = useState({}) + const { toast } = useToast() + const isApiReady = useMemo( + () => Object.keys(api).length === units.length, + [api] + ) + const loadApi = useCallback(async () => { + try { + await metacom.load(...units) + } catch (e) { + toast({ + title: 'Metacom loading error', + variant: 'destructive', + }) + } + setApi(metacom.api) + }, [toast]) + + useEffect(() => { + if (!isApiReady) loadApi() + }, [isApiReady, loadApi]) + + return { api, isApiReady } +} + +export const ApiContext = createContext(null) +export const useApiContext = () => useContext(ApiContext) diff --git a/src/utils/index.ts b/src/utils/index.ts index 535b061..9c21d5b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './misc' +export * from './api'