From c983b6c42f97c1e9cc7a5d1b449a897d2296a55c Mon Sep 17 00:00:00 2001 From: Francesco Lorenzo D'Amico Date: Thu, 19 Mar 2026 11:26:24 +0100 Subject: [PATCH] Build chat UI with real-time messaging and i18n --- frontend/package-lock.json | 186 ++++++++++++++- frontend/package.json | 5 +- frontend/src/App.css | 433 ++++++++++++++++++++------------- frontend/src/App.jsx | 475 ++++++++++++++++++++++++++++--------- frontend/src/index.css | 110 ++------- 5 files changed, 846 insertions(+), 363 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d1013f..713c169 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "i18next": "^25.8.18", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-i18next": "^16.5.8", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -215,6 +218,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -855,6 +867,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1162,7 +1180,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1200,6 +1217,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1575,6 +1614,46 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.18", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.18.tgz", + "integrity": "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2044,7 +2123,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2253,6 +2331,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "16.5.8", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.8.tgz", + "integrity": "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2343,6 +2448,34 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2458,6 +2591,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", @@ -2537,6 +2679,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2563,6 +2714,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "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/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 65f2ba8..602ef06 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "i18next": "^25.8.18", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-i18next": "^16.5.8", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/frontend/src/App.css b/frontend/src/App.css index f90339d..29d1649 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,184 +1,291 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { +.app-shell { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; display: flex; flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } + gap: 1.5rem; } -#next-steps { +.page-header { display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } + justify-content: space-between; + gap: 1rem; + align-items: flex-start; } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +.page-header h1 { + font-size: clamp(1.8rem, 3vw, 2.4rem); + margin: 0.25rem 0; } -#next-steps ul { +.eyebrow { + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.1em; + color: var(--muted); + margin: 0; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.85rem; + border-radius: 999px; + font-size: 0.9rem; + border: 1px solid transparent; +} + +.status-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: currentColor; +} + +.status-idle { + color: #64748b; + border-color: rgba(100, 116, 139, 0.3); +} +.status-connecting, +.status-reconnecting { + color: #a16207; + border-color: rgba(161, 98, 7, 0.35); +} +.status-connected { + color: #059669; + border-color: rgba(5, 150, 105, 0.3); +} +.status-error { + color: #dc2626; + border-color: rgba(220, 38, 38, 0.35); +} + +.panel { + background: rgba(15, 23, 42, 0.4); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 1rem; + padding: 1.5rem; +} + +.session-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.field-group { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.field-group label { + font-size: 0.85rem; + color: var(--muted); +} + +.field-group input { + border-radius: 0.65rem; + padding: 0.75rem 1rem; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(15, 23, 42, 0.6); + color: inherit; +} + +.field-group input:focus-visible { + outline: 2px solid rgba(94, 234, 212, 0.8); + border-color: transparent; +} + +.actions { + display: flex; + gap: 0.75rem; + align-items: flex-end; +} + +button { + border: none; + border-radius: 0.65rem; + padding: 0.75rem 1.5rem; + font-weight: 600; + cursor: pointer; + background: linear-gradient(135deg, #38bdf8, #6366f1); + color: white; + flex-shrink: 0; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.ghost { + background: transparent; + border: 1px solid rgba(148, 163, 184, 0.4); +} + +.form-error { + color: #f87171; + margin-top: 0.75rem; +} + +/* Centralised error style classes */ + +/* Inline text error (e.g. below a field) */ +.error-text { + color: #f87171; + font-size: 0.8rem; + margin-top: 0.25rem; +} + +/* Input border highlight when a field is invalid */ +.input-error { + border-color: #f87171 !important; + outline-color: rgba(248, 113, 113, 0.6); +} + +/* Full-width server / API error banner */ +.server-error { + color: #f87171; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: 0.65rem; + padding: 0.75rem 1rem; + font-size: 0.9rem; +} + +.chat-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1.5rem; +} + +.chat-panel, +.participants-panel { + background: rgba(15, 23, 42, 0.5); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 1rem; + display: flex; + flex-direction: column; + min-height: 540px; +} + +.chat-panel__header, +.participants-panel header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + display: flex; + align-items: center; + justify-content: space-between; +} + +.muted { + color: var(--muted); +} + +.chat-panel__body { + flex: 1; + padding: 1.5rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.empty-state { + text-align: center; + color: var(--muted); +} + +.message { + border-radius: 0.85rem; + padding: 0.75rem 1rem; + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(148, 163, 184, 0.1); +} + +.message.me { + border-color: rgba(94, 234, 212, 0.8); + background: rgba(15, 118, 110, 0.2); +} + +.message header { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 0.35rem; +} + +.composer { + display: flex; + gap: 0.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.2); + padding: 1rem 1.5rem 1.5rem; +} + +.composer input { + flex: 1; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.3); + padding: 0.85rem 1.2rem; + background: rgba(15, 23, 42, 0.6); + color: inherit; +} + +.participants-panel ul { list-style: none; - padding: 0; + margin: 0; + padding: 1.25rem 1.5rem; display: flex; - gap: 8px; - margin: 32px 0 0; + flex-direction: column; + gap: 0.85rem; + flex: 1; +} - .logo { - height: 18px; - } +.participants-panel li { + display: flex; + gap: 0.75rem; + align-items: center; +} - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; +.presence { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; +} - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } +.presence.online { + background: #22c55e; + box-shadow: 0 0 12px rgba(34, 197, 94, 0.4); +} - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; +.presence.away { + background: #eab308; +} - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } +@media (max-width: 960px) { + .chat-grid { + grid-template-columns: 1fr; } } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); +@media (max-width: 640px) { + .app-shell { + padding: 1.25rem; + } + + .page-header { + flex-direction: column; + } + + .session-form { + grid-template-columns: 1fr; + } + + .actions { + justify-content: flex-start; } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b2bf2e8..d17795e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,121 +1,384 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { io } from 'socket.io-client' +import { ERROR_MESSAGES } from './constants/errorMessages' +import { validateJoinForm } from './utils/validators' import './App.css' +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000' + function App() { - const [count, setCount] = useState(0) + const { t } = useTranslation() + + const [username, setUsername] = useState('') + const [room, setRoom] = useState('general') + const [session, setSession] = useState(null) + const [status, setStatus] = useState('idle') + const [messages, setMessages] = useState([]) + const [participants, setParticipants] = useState([]) + const [messageInput, setMessageInput] = useState('') + const [error, setError] = useState('') + + const socketRef = useRef(null) + const messagesEndRef = useRef(null) + + const connectionLabel = t(`status.${status}`, { defaultValue: status }) + const isConnected = status === 'connected' + + useEffect(() => { + if (!session?.id || !session?.room) { + setStatus('idle') + return + } + + let isCancelled = false + let retryCount = 0 + let reconnectTimer + + const connect = () => { + if (isCancelled) return + setStatus(retryCount === 0 ? 'connecting' : 'reconnecting') + const socket = io(API_URL, { + auth: { + room: session.room, + userId: session.id, + }, + reconnectionAttempts: 5, + transports: ['websocket'], + }) + socketRef.current = socket + + socket.on('connect', () => { + retryCount = 0 + setStatus('connected') + }) + + socket.io.on('reconnect_attempt', () => { + if (!isCancelled) { + setStatus('reconnecting') + } + }) + + socket.io.on('reconnect_failed', () => { + if (!isCancelled) { + setStatus('error') + } + }) + + socket.on('connect_error', (socketError) => { + if (isCancelled) return + setError(socketError.message || ERROR_MESSAGES.connection.failed) + setStatus('error') + }) + + socket.on('disconnect', (reason) => { + if (isCancelled) return + if (reason !== 'io client disconnect' && retryCount < 5) { + retryCount += 1 + setStatus('reconnecting') + } else { + setStatus('error') + } + }) + + socket.on('history:init', (payload) => { + setMessages(Array.isArray(payload) ? payload : []) + }) + + socket.on('message:new', (payload) => { + if (payload) { + setMessages((prev) => [...prev, payload]) + } + }) + + socket.on('participants:update', (payload) => { + setParticipants(Array.isArray(payload) ? payload : []) + }) + + socket.on('connection:ready', () => { + setError('') + }) + } + + reconnectTimer = setTimeout(() => { + if (!socketRef.current?.connected && !isCancelled) { + setStatus('connecting') + } + }, 0) + + connect() + + return () => { + isCancelled = true + clearTimeout(reconnectTimer) + socketRef.current?.disconnect() + socketRef.current = null + } + }, [session?.id, session?.room]) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const participantCount = participants.length + + const handleJoin = async (event) => { + event.preventDefault() + const validationError = validateJoinForm(username, room) + if (validationError) { + setError(validationError) + return + } + + setError('') + setStatus(session ? 'reconnecting' : 'connecting') + try { + const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: username.trim(), + userId: session?.id, + }), + }) + + if (!response.ok) { + throw new Error(ERROR_MESSAGES.api.joinFailed) + } + + const data = await response.json() + if (!data?.user) { + throw new Error(ERROR_MESSAGES.api.noUserReturned) + } + setSession(data.user) + setUsername(data.user.username) + setRoom(data.user.room) + await fetchRoomSnapshots(data.user.room) + } catch (joinError) { + console.error(joinError) + setError(joinError.message) + setStatus('error') + } + } + + const fetchRoomSnapshots = async (targetRoom) => { + const encodedRoom = encodeURIComponent(targetRoom) + try { + const [messagesResponse, participantsResponse] = await Promise.all([ + fetch(`${API_URL}/rooms/${encodedRoom}/messages`), + fetch(`${API_URL}/rooms/${encodedRoom}/participants`), + ]) + if (messagesResponse.ok) { + const { messages: history } = await messagesResponse.json() + setMessages(history) + } + if (participantsResponse.ok) { + const { participants: list } = await participantsResponse.json() + setParticipants(list) + } + } catch (snapshotError) { + console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError) + } + } + + const handleSendMessage = (event) => { + event.preventDefault() + if (!session || !messageInput.trim()) { + return + } + const socket = socketRef.current + if (!socket || !socket.connected) { + setError(ERROR_MESSAGES.connection.noActiveConnection) + return + } + try { + socket.emit('message:send', { + text: messageInput.trim(), + }) + setMessageInput('') + setError('') + } catch (sendError) { + console.error(sendError) + setError(ERROR_MESSAGES.connection.sendFailed) + } + } + + const handleLeave = async () => { + if (!session) return + try { + await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: session.id }), + }) + } catch (leaveError) { + console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError) + } finally { + setSession(null) + setMessages([]) + setParticipants([]) + setStatus('idle') + } + } + + const connectionHint = useMemo(() => { + if (!session) return t('hint.noSession') + switch (status) { + case 'connecting': return t('hint.connecting') + case 'reconnecting': return t('hint.reconnecting') + case 'error': return t('hint.error') + default: return t('hint.joined', { room: session.room, username: session.username }) + } + }, [session, status, t]) return ( - <> -
-
- - React logo - Vite logo -
+
+
-

Get started

-

- Edit src/App.jsx and save to test HMR -

+

{t('app.title')}

+

{t('app.headline')}

+

{connectionHint}

- +
+ + {connectionLabel} +
+
+ +
+
+
+ + setUsername(event.target.value)} + /> +
+
+ + setRoom(event.target.value.toLowerCase())} + /> +
+
+ + {session ? ( + + ) : null} +
+
+ {error ?

{t(error)}

: null}
-
- -
-
- -

Documentation

-

Your questions, answered

+
+
+
+
+

{t('chat.title')}

+

{t('chat.liveFeed', { room: session?.room ?? room })}

+
+ {t('chat.messageCount', { count: messages.length })} +
+
+ {messages.length === 0 ? ( +

{t('chat.empty')}

+ ) : ( + messages.map((message) => ( +
+
+ {message.username} + {formatTime(message.createdAt)} +
+

{message.text}

+
+ )) + )} +
+
+
+ setMessageInput(event.target.value)} + placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')} + disabled={!session || !isConnected} + /> + +
+
+
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + +
) } +function formatTime(timestamp) { + if (!timestamp) return '' + return new Intl.DateTimeFormat('de-DE', { + hour: '2-digit', + minute: '2-digit', + }).format(new Date(timestamp)) +} + +function formatRelativeTime(timestamp) { + if (!timestamp) return '' + const formatter = new Intl.RelativeTimeFormat('de', { numeric: 'auto' }) + const now = Date.now() + const then = new Date(timestamp).getTime() + const diffInMinutes = Math.round((then - now) / (1000 * 60)) + + if (Math.abs(diffInMinutes) < 60) { + return formatter.format(diffInMinutes, 'minute') + } + const diffInHours = Math.round(diffInMinutes / 60) + if (Math.abs(diffInHours) < 24) { + return formatter.format(diffInHours, 'hour') + } + const diffInDays = Math.round(diffInHours / 24) + return formatter.format(diffInDays, 'day') +} + export default App diff --git a/frontend/src/index.css b/frontend/src/index.css index 2c84af0..7d0b47e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,111 +1,41 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + color-scheme: dark; + font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, + sans-serif; + font-size: 16px; + background-color: #020617; + color: #e2e8f0; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } + --muted: #94a3b8; } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } +* { + box-sizing: border-box; } body { margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top, rgba(14, 165, 233, 0.2), transparent 55%), + radial-gradient(circle at 20% 20%, rgba(124, 58, 237, 0.2), transparent 35%), + #020617; } #root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; + min-height: 100vh; } h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} +h2, p { margin: 0; } -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); +a { + color: inherit; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +input, +button { + font: inherit; }