diff --git a/.env.example b/.env.example index 2ed16d9..f2a3612 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ POSTGRES_URL=postgres://postgres:postgres@localhost:5432/forge -BACKEND_URL=http://localhost:8080 \ No newline at end of file +BACKEND_URL=http://localhost:8080 +API_KEY=1234567890 diff --git a/package.json b/package.json index 02aa86d..e85dd79 100644 --- a/package.json +++ b/package.json @@ -1,66 +1,73 @@ { - "name": "riven-frontend", - "version": "1.0.0", - "private": false, - "scripts": { - "dev": "next dev --turbopack", - "build": "next build", - "start": "next start", - "lint": "next lint", - "lint:fix": "next lint --fix", - "typecheck": "tsc --noEmit", - "format": "prettier --check \"**/*.{ts,tsx}\" --cache", - "format:fix": "prettier --write \"**/*.{ts,tsx}\" --cache", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio", - "generate-client": "openapi-ts" - }, - "dependencies": { - "@hey-api/client-fetch": "^0.8.1", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dropdown-menu": "^2.1.5", - "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-select": "^2.1.5", - "@tailwindcss/postcss": "^4.0.3", - "bcryptjs": "^2.4.3", - "class-variance-authority": "^0.7.1", - "drizzle-orm": "^0.39.3", - "jose": "^5.9.6", - "lucide-react": "^0.475.0", - "next": "^15.2.0-canary.54", - "next-themes": "^0.4.4", - "postgres": "^3.4.5", - "react": "19.0.0", - "react-dom": "19.0.0", - "sonner": "^1.7.4", - "tailwind-merge": "^3.0.1", - "tailwindcss": "^4.0.3", - "vaul": "^1.1.2", - "zod": "^3.24.2" - }, - "devDependencies": { - "@eslint/js": "^9.19.0", - "@hey-api/openapi-ts": "^0.64.1", - "@ianvs/prettier-plugin-sort-imports": "^4.4.1", - "@next/bundle-analyzer": "^15.1.6", - "@next/eslint-plugin-next": "^15.1.6", - "@types/bcryptjs": "^2.4.6", - "@types/node": "^22.13.0", - "@types/react": "19.0.8", - "@types/react-dom": "19.0.3", - "drizzle-kit": "^0.30.4", - "eslint": "^9.19.0", - "eslint-config-next": "15.1.6", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-promise": "^7.2.1", - "eslint-plugin-react": "^7.37.4", - "eslint-plugin-tailwindcss": "^3.18.0", - "globals": "^15.14.0", - "postcss": "^8.5.1", - "prettier": "^3.4.2", - "prettier-plugin-tailwindcss": "^0.6.11", - "typescript": "^5.7.3", - "typescript-eslint": "^8.22.0" - } + "name": "riven-frontend", + "version": "1.0.0", + "private": false, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "typecheck": "tsc --noEmit", + "format": "prettier --check \"**/*.{ts,tsx}\" --cache", + "format:fix": "prettier --write \"**/*.{ts,tsx}\" --cache", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "generate-client": "openapi-ts", + "prepare": "next-ws patch" + }, + "dependencies": { + "@formkit/auto-animate": "^0.8.2", + "@hey-api/client-fetch": "^0.8.1", + "@leo1305/custom-next-ws": "^1.0.0", + "@radix-ui/react-checkbox": "^1.1.3", + "@radix-ui/react-dropdown-menu": "^2.1.5", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-select": "^2.1.5", + "@radix-ui/react-toast": "^1.2.5", + "@tailwindcss/postcss": "^4.0.3", + "@types/ws": "^8.5.14", + "bcryptjs": "^2.4.3", + "class-variance-authority": "^0.7.1", + "drizzle-orm": "^0.39.3", + "jose": "^5.9.6", + "lucide-react": "^0.475.0", + "next": "^15.2.0-canary.54", + "next-themes": "^0.4.4", + "postgres": "^3.4.5", + "react": "19.0.0", + "react-dom": "19.0.0", + "sonner": "^1.7.4", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^4.0.3", + "vaul": "^1.1.2", + "ws": "^8.18.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@eslint/js": "^9.19.0", + "@hey-api/openapi-ts": "^0.64.1", + "@ianvs/prettier-plugin-sort-imports": "^4.4.1", + "@next/bundle-analyzer": "^15.1.6", + "@next/eslint-plugin-next": "^15.1.6", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/bcryptjs": "^2.4.6", + "@types/node": "^22.13.0", + "@types/react": "19.0.8", + "@types/react-dom": "19.0.3", + "drizzle-kit": "^0.30.4", + "eslint": "^9.19.0", + "eslint-config-next": "15.1.6", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-promise": "^7.2.1", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-tailwindcss": "^3.18.0", + "globals": "^15.14.0", + "postcss": "^8.5.1", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript": "^5.7.3", + "typescript-eslint": "^8.22.0" + } } diff --git a/src/app/api/ws/[topic]/socket/route.ts b/src/app/api/ws/[topic]/socket/route.ts new file mode 100644 index 0000000..681ef17 --- /dev/null +++ b/src/app/api/ws/[topic]/socket/route.ts @@ -0,0 +1,37 @@ +import { env } from 'node:process' + +const websocketUrl = (env.BACKEND_URL || 'http://localhost:8080').replace(/^http/, 'ws') +const apiKey = env.API_KEY + +export function SOCKET( + client: import('ws').WebSocket, + request: import('http').IncomingMessage, + server: import('ws').WebSocketServer, + { params }: { params: { topic: string } } +) { + console.log('A client connected') + + const nativeWebSocket = new WebSocket(`${websocketUrl}/api/v1/ws/${params.topic}?api_key=${apiKey}`) + + client.onmessage = (message) => { + nativeWebSocket.send(message.data.toString()) + } + + nativeWebSocket.onmessage = (message) => { + client.send(message.data) + } + + nativeWebSocket.onerror = (error) => { + console.error('WebSocket error:', error) + } + + client.onclose = () => { + console.log('client closed') + nativeWebSocket.close() + } + + nativeWebSocket.onclose = () => { + console.log('nativeWebSocket closed') + client.close() + } +} diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx new file mode 100644 index 0000000..4a476b2 --- /dev/null +++ b/src/app/events/page.tsx @@ -0,0 +1,103 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Badge } from '@/components/ui/badge' +import { useAutoAnimate } from '@formkit/auto-animate/react' + +type EventData = { + [key: string]: string[] +} + +function getNewWebSocket() { + return new WebSocket('/api/ws/event_update/socket') +} + +export default function EventsPage() { + const [events, setEvents] = useState() + const [status, setStatus] = useState('Disconnected') + const [ws, setWs] = useState() + const [listRef] = useAutoAnimate() + const [itemsRef] = useAutoAnimate() + + useEffect(() => { + const websocket = getNewWebSocket() + + websocket.onopen = () => { + setStatus('Connected') + console.log('Connected to WebSocket') + } + + websocket.onclose = () => { + setStatus('Disconnected') + console.log('Disconnected from WebSocket') + + // Attempt to reconnect after 3 seconds + setTimeout(() => { + setStatus('Reconnecting...') + setWs(undefined) + const newWebsocket = getNewWebSocket() + setWs(newWebsocket) + }, 3000) + } + + websocket.onerror = (error) => { + console.error('WebSocket error:', error) + setStatus('Error') + } + + websocket.onmessage = (event) => { + const data = JSON.parse(event.data) + console.log('Received data:', data) + setEvents(data.data) + } + + setWs(websocket) + + // Cleanup on component unmount + return () => { + if (websocket) { + websocket.close() + } + } + }, []) + + return ( + <> +
+ {status === 'Connected' && Connected} + {status === 'Disconnected' && Disconnected} + {status === 'Reconnecting...' && Reconnecting...} + {status === 'Error' && Error} +
+
+ {events === undefined &&

Loading...

} + + {events === null &&

Error

} + {events && ( +
+

Events

+ {/* Apply animation to the container of event groups */} +
+ {Object.entries(events).map(([key, value]) => ( +
+

{key}

+ {/* Apply animation to the container of individual events */} +
    + {value.map((event) => ( +
  • +
    +

    {event}

    +
    +
  • + ))} +
+
+ ))} +
+
+ )} +
+ + ) +}