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 7ace0b9..e7dd272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,12 @@ "name": "golub", "version": "0.0.1", "dependencies": { + "metacom": "^3.1.2", "next": "14.0.3", "next-themes": "^0.2.1", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hot-toast": "^2.4.1" }, "devDependencies": { "@types/node": "^20", @@ -2155,8 +2157,7 @@ "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -3373,6 +3374,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz", + "integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -4314,6 +4323,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", @@ -5105,6 +5142,21 @@ "react": "^18.2.0" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6333,6 +6385,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 27cafb4..320c326 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,17 @@ "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 ." }, "dependencies": { + "metacom": "^3.1.2", "next": "14.0.3", "next-themes": "^0.2.1", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hot-toast": "^2.4.1" }, "devDependencies": { "@types/node": "^20", diff --git a/project-words.txt b/project-words.txt index bf2e7dd..94d25b0 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1,4 +1,5 @@ Autobuild +autofetch GOLUB tailwindcss tsbuildinfo diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 0bccbd0..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 229d5bd..a736b86 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,25 +1,25 @@ -import type { Metadata } from 'next' +'use client' import Providers from '@/store/Providers' +import { FC } from 'react' +import { Toaster } from 'react-hot-toast' import { text, title } from './fonts' import './globals.css' -export const metadata: Metadata = { - description: 'Generated by create next app', - title: 'Create Next App', +interface PageProps { + children: React.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 1b5be08..b3de169 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,29 +1,28 @@ -import IconChange from '@/components/UI/Icons/IconChange' -import Image from 'next/image' +'use client' + +import Button from '@/components/UI/Button' +import { useApi, useApiContext } from '@/utils' +import { notify } from '@/utils/notifications' + +const Home = () => { + const api = useApiContext() + const condition = true + + const { fetch, response: calculated } = useApi( + () => condition && api.example.add({ a: 1, b: 2 }), + { onSuccess: () => notify('It works') } + ) + 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
-
🚀
+
+ + {calculated} +
{data}
) } + +export default Home diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index 9abd7bc..b039a3c 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -5,7 +5,7 @@ type ButtonProps = { children: ReactNode className?: string disabled?: boolean - onClick?: () => void + onClick?: () => Promise | void type?: 'button' | 'reset' | 'submit' } 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.tsx b/src/store/Providers.tsx index 0f6c567..7f90f8e 100644 --- a/src/store/Providers.tsx +++ b/src/store/Providers.tsx @@ -1,5 +1,6 @@ 'use client' +import { ApiContext, useApiLoader } from '@/utils' import { ThemeProvider } from 'next-themes' import { FC, ReactNode } from 'react' @@ -8,7 +9,15 @@ type ProvidersProps = { } const Providers: FC = ({ children }) => { - return {children} + const { api, isApiReady } = useApiLoader() + + return ( + + {isApiReady && api && ( + {children} + )} + + ) } export default Providers diff --git a/src/utils/api/hooks.ts b/src/utils/api/hooks.ts new file mode 100644 index 0000000..14e6106 --- /dev/null +++ b/src/utils/api/hooks.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { notify } from '..' + +export const useApi = >( + fetcher: () => '' | T | false | null | undefined, + options: { + autofetch?: boolean + onBeforeFetch?: () => Promise | void + onError?: () => void + onResponse?: (res: Awaited) => Promise | void + onSuccess?: () => void + } = {} +) => { + 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) { + notify('Network error', { type: 'error' }) + console.error(e) + } + } + setLoading(false) + }, []) + + 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..bb8440d --- /dev/null +++ b/src/utils/api/index.ts @@ -0,0 +1,2 @@ +export * from './metacom' +export * from './hooks' diff --git a/src/utils/api/metacom.ts b/src/utils/api/metacom.ts new file mode 100644 index 0000000..cdb55bb --- /dev/null +++ b/src/utils/api/metacom.ts @@ -0,0 +1,36 @@ +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 isApiReady = useMemo( + () => Object.keys(api).length === units.length, + [api] + ) + const loadApi = useCallback(async () => { + await metacom.load(...units) + setApi(metacom.api) + }, []) + + useEffect(() => { + ;(async () => { + 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..9e21e13 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,3 @@ export * from './misc' +export * from './api' +export * from './notifications' diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 0000000..d1e04b7 --- /dev/null +++ b/src/utils/notifications.ts @@ -0,0 +1,23 @@ +import toast from 'react-hot-toast' + +interface NotifyProps { + duration?: number + type?: 'error' | 'info' | 'success' +} + +export const notify = ( + message: Parameters[0], + { duration = 4000, type = 'success' }: NotifyProps = {} +) => { + const options = { duration } + + if (type === 'info') { + toast(message, { + ...options, + }) + } else if (type === 'error') { + toast.error(message, options) + } else { + toast.success(message, options) + } +}