Skip to content
3 changes: 3 additions & 0 deletions whitewash/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
}
},
"domains": {
Expand Down
189 changes: 116 additions & 73 deletions whitewash/src/app/components/chat/OddyChat.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,100 @@
"use client"

import { useStateArray } from "@/hooks/useStateArray"
import Image, { type StaticImageData } from "next/image"
import oddy from "@/assets/images/oddy.png"
import frog from "@/assets/images/frog.png"
import prideFrog from "@/assets/images/pride-froggy.png"
import styles from "../oddy/Oddy.module.css"
import { useEffect, useRef, useState } from "react"
import { useStateArray } from "@/hooks/useStateArray"

type ChatMsg = {
id: string
role: "user" | "oddy"
message: string
role: "user" | "assistant"
content: string
}

type OddyProps = {
type OddyChatProps = {
isFrog?: boolean
isPride?: boolean
previousMessages?: ChatMsg[]
}

export const OddyChat = ({ previousMessages }: OddyProps) => {
export const OddyChat = ({
isFrog = false,
isPride = false,
previousMessages,
}: OddyChatProps) => {
const [avatar, setAvatar] = useState<StaticImageData>(oddy)
const [name, setName] = useState<"Froggy" | "Oddy">(
isFrog ? "Froggy" : "Oddy",
)
previousMessages = previousMessages !== undefined ? [...previousMessages] : []

const [messages, addMessage] = useStateArray<ChatMsg>(previousMessages)
const [input, setInput] = useState("")
const [agent, setAgent] = useState<"oddy" | "froggy">("froggy")
const [isPride, setIsPride] = useState(false)
const ws = useRef<WebSocket | null>(null)

const messagesEndRef = useRef(null)

const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
})
})
}, [])

useEffect(() => {
scrollToBottom()
}, [scrollToBottom])

// pick correct avatar on mount or when props change
useEffect(() => {
if (isFrog) {
setAvatar(isPride ? prideFrog : frog)
setName("Froggy")
} else {
setAvatar(oddy)
setName("Oddy")
}
}, [isFrog, isPride])

// connect websocket
useEffect(() => {
const agent = isFrog ? "froggy" : "oddy"
const url = new URL("ws://localhost:4001")
url.searchParams.set("agent", agent)
url.searchParams.set("isPride", String(isPride))
url.searchParams.set(
"prevMessages",
JSON.stringify(
previousMessages.map((p) => {
return { role: p.role, content: p.content }
}),
),
)

ws.current?.close()
ws.current = new WebSocket(url.toString())

ws.current.onopen = () => {
console.log("✅ Connected to WebSocket")
}

ws.current.onmessage = (event) => {
if (event.data === "Successfully connected") return
if (event.data === "Successfully connected")
return () => ws.current?.close()
addMessage({
id: crypto.randomUUID(),
role: "oddy",
message: String(event.data),
role: "assistant",
content: String(event.data),
})
scrollToBottom()
}

ws.current.onerror = (err) => console.error("❌ WebSocket error:", err)
ws.current.onclose = () => console.log("🔌 WebSocket closed")

return () => {
ws.current?.close()
}
}, [agent, isPride, addMessage])
return () => ws.current?.close()
}, [isFrog, isPride, addMessage, previousMessages.map, ])

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
Expand All @@ -59,69 +104,67 @@ export const OddyChat = ({ previousMessages }: OddyProps) => {
const msg: ChatMsg = {
id: crypto.randomUUID(),
role: "user",
message: trimmed,
content: trimmed,
}
addMessage(msg)
ws.current?.send(trimmed)
setInput("")
}

return (
<div className="fixed bottom-6 right-6 z-50 w-[min(90vw,420px)]">
<div className="mb-2 flex items-center gap-3">
<select
className="border rounded px-2 py-1 text-sm"
value={agent}
onChange={(e) => setAgent(e.target.value as "oddy" | "froggy")}
>
<option value="oddy">Oddy</option>
<option value="froggy">Froggy</option>
</select>

<label className="text-sm flex items-center gap-2">
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-4 bg-rema-secondary-lightblue rounded-3xl p-5 backdrop-blur">
<Image
src={avatar}
alt={`${name} avatar`}
width={80}
height={80}
className={`rounded-full ${isFrog ? styles.spinSlow : ""}`}
/>

<div className="w-[min(90vw,420px)] flex flex-col items-end">
<div className="max-h-[50vh] min-h-100 overflow-auto p-3 space-y-2 w-full bg-white rounded-lg">
{previousMessages.length === 0 ? (
<p className="max-w-[85%] rounded px-3 py-2 bg-gray-100 text-gray-900 self-start">
{!isFrog
? "Heisann sveisann, jeg er Odd Reitan, men du kan kalle meg Oddy!"
: "Ribbit!"}
</p>
) : (
""
)}
{messages
.filter((v) => v.id !== "0")
.map((m) => (
<p
key={m.id}
className={`max-w-[85%] rounded px-3 py-2 ${
m.role === "assistant"
? "bg-gray-100 text-gray-900 self-start"
: "bg-blue-600 text-white self-end ml-auto"
}`}
>
{m.content}
</p>
))}
<div ref={messagesEndRef} />
</div>

<form onSubmit={handleSubmit} className="mt-2 flex gap-2 w-full">
<input
type="checkbox"
checked={isPride}
onChange={(e) => setIsPride(e.target.checked)}
className="border rounded p-2 flex-1 bg-white border-rema-secondary-darkblue"
value={input}
onFocus={scrollToBottom}
onChange={(e) => setInput(e.target.value)}
placeholder="Skriv meldingen din…"
/>
Pride
</label>
</div>

<div className="bg-white/90 backdrop-blur rounded-lg shadow max-h-[50vh] overflow-auto p-3 space-y-2">
{messages.map((m) => (
<p
key={m.id}
className={`max-w-[85%] rounded px-3 py-2 ${
m.role === "oddy"
? "bg-gray-100 text-gray-900 self-start"
: "bg-blue-600 text-white self-end ml-auto"
}`}
<button
type="submit"
className="bg-blue-600 text-white rounded px-4 py-2 hover:bg-blue-700 transition"
>
{m.message}
</p>
))}
{messages.length === 0 && (
<p className="text-gray-500 text-sm">
Si hei til {agent === "froggy" ? "Froggy 🐸" : "Oddy"}…
</p>
)}
Send
</button>
</form>
</div>

<form onSubmit={handleSubmit} className="mt-2 flex gap-2">
<input
className="border rounded p-2 flex-1"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Skriv meldingen din…"
/>
<button
type="submit"
className="bg-blue-600 text-white rounded px-4 py-2 hover:bg-blue-700 transition"
>
Send
</button>
</form>
</div>
)
}
93 changes: 33 additions & 60 deletions whitewash/src/app/components/oddy/Oddy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,62 +5,52 @@ import oddy from "@/assets/images/oddy.png"
import frog from "@/assets/images/frog.png"
import prideFrog from "@/assets/images/pride-froggy.png"
import styles from "./Oddy.module.css"
import * as React from "react"
import Popover from "@mui/material/Popover"
import Typography from "@mui/material/Typography"
import { useEffect, useState } from "react"

export const Oddy = ({
message = "Hei! Jeg er Oddy, kan jeg hjelpe deg med noe?",
}: {
type OddyProps = {
message?: string
}) => {
const [isFrog, setIsFrog] = useState(false)
const [isPride, setIsPride] = useState(false)
const [avatar, setAvatar] = useState<StaticImageData>(oddy)
const [name, setName] = useState<"Froggy" | "Oddy">("Oddy")
onClick?: () => void
isFrog?: boolean
isPride?: boolean
}

useEffect(() => {
const shouldBeFrog = Math.random() < 1 / 20
setIsFrog(shouldBeFrog)
if (shouldBeFrog) {
const shouldBePride = Math.random() < 1 / 10
setIsPride(shouldBePride)
setAvatar(shouldBePride ? prideFrog : frog)
setName("Froggy")
}
}, [])
export const Oddy = ({
message = "Hei! Jeg er Oddy, kan jeg hjelpe deg med noe?",
onClick,
isFrog = false,
isPride = false,
}: OddyProps) => {
let avatar: StaticImageData
let name: "Froggy" | "Oddy"

const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget)
if (isFrog) {
avatar = isPride ? prideFrog : frog
name = "Froggy"
} else {
avatar = oddy
name = "Oddy"
}
const handleClose = () => setAnchorEl(null)
const open = Boolean(anchorEl)

return (
<div
id="chat-container"
className="flex flex-col items-end gap-3 z-50 fixed bottom-6 right-6"
>
<button type="button" onClick={onClick} className="cursor-pointer">
<Image
src={avatar}
alt={`${name} avatar`}
width={80}
height={80}
className={`rounded-full ${isFrog ? styles.spinSlow : ""}`}
/>
</button>

<button
id="open-chat-button"
onClick={handleClick}
id="oddy_container"
className="relative px-4 py-3 rounded-lg cursor-pointer"
type="button"
className="bg-white rounded text-black p-3 shadow hover:bg-gray-100 transition"
onClick={onClick}
>
Ask {name}
</button>

<Image
src={avatar}
alt={`${name} avatar`}
width={80}
height={80}
className={`rounded-full ${isFrog ? styles.spinSlow : ""}`}
/>

<div id="oddy_container" className="relative px-4 py-3 rounded-lg">
<div className="relative bg-gray-200 px-4 py-3 rounded-lg text-gray-900 shadow">
{isFrog
? message.replace(
Expand All @@ -70,24 +60,7 @@ export const Oddy = ({
: message}
<div className="absolute top-[-8px] right-4 w-0 h-0 border-l-8 border-r-8 border-b-8 border-transparent border-b-gray-200" />
</div>
</div>

<Popover
className="mt-6"
open={open}
onClose={handleClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
>
<Typography sx={{ p: 2 }}>Bruh momento.</Typography>
</Popover>
</button>
</div>
)
}
Loading