feat(city-map): Add 2D City Map with coordinates and agent presence

- Add migration 013_city_map_coordinates.sql with map coordinates, zones, and agents table
- Add /city/map API endpoint in city-service
- Add /city/agents and /city/agents/online endpoints
- Extend presence aggregator to include agents[] in snapshot
- Add AgentsSource for fetching agent data from DB
- Create CityMap component with interactive room tiles
- Add useCityMap hook for fetching map data
- Update useGlobalPresence to include agents
- Add map/list view toggle on /city page
- Add agent badges to room cards and map tiles
This commit is contained in:
Apple
2025-11-27 07:00:47 -08:00
parent 3de3c8cb36
commit 6bd769ef40
258 changed files with 1747 additions and 79 deletions

View File

@@ -1,60 +1,74 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Mail, Lock, Loader2, Sparkles, AlertCircle } from 'lucide-react'
import { useAuth } from '@/context/AuthContext'
import { login as authLogin } from '@/lib/auth'
import { cn } from '@/lib/utils'
export default function LoginPage() {
const router = useRouter()
const { login, isAuthenticated } = useAuth()
const { refreshUser, isAuthenticated } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
// Redirect if already authenticated
// Redirect if already authenticated (client-side effect to avoid rendering push)
useEffect(() => {
if (isAuthenticated) {
router.push('/')
}
}, [isAuthenticated, router])
if (isAuthenticated) {
router.push('/')
return null
}
const validateForm = (): string | null => {
if (!email.trim()) {
const validateForm = (currentEmail: string, currentPassword: string): string | null => {
if (!currentEmail.trim()) {
return 'Введіть email адресу'
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
if (!emailRegex.test(currentEmail)) {
return 'Введіть коректну email адресу'
}
if (!password) {
if (!currentPassword) {
return 'Введіть пароль'
}
if (password.length < 8) {
if (currentPassword.length < 8) {
return 'Пароль повинен містити мінімум 8 символів'
}
return null
}
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError('')
// Client-side validation
const validationError = validateForm()
const formData = new FormData(e.currentTarget)
const fallbackEmail = (formData.get('email')?.toString() ?? '').trim()
const fallbackPassword = formData.get('password')?.toString() ?? ''
const currentEmail = email.trim() || fallbackEmail
const currentPassword = password || fallbackPassword
if (!email && currentEmail) setEmail(currentEmail)
if (!password && currentPassword) setPassword(currentPassword)
const validationError = validateForm(currentEmail, currentPassword)
if (validationError) {
setError(validationError)
return
}
setLoading(true)
try {
await login(email, password)
await authLogin(currentEmail, currentPassword)
await refreshUser()
router.push('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'Помилка входу. Перевірте дані та спробуйте ще раз.')
@@ -100,6 +114,7 @@ export default function LoginPage() {
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -124,6 +139,7 @@ export default function LoginPage() {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -157,6 +173,7 @@ export default function LoginPage() {
'Увійти'
)}
</button>
</form>
{/* Divider */}

View File

@@ -5,11 +5,12 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Mail, Lock, User, Loader2, Sparkles, AlertCircle, CheckCircle2 } from 'lucide-react'
import { useAuth } from '@/context/AuthContext'
import { register as authRegister, login as authLogin } from '@/lib/auth'
import { cn } from '@/lib/utils'
export default function RegisterPage() {
const router = useRouter()
const { register, isAuthenticated } = useAuth()
const { refreshUser, isAuthenticated } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -34,18 +35,22 @@ export default function RegisterPage() {
const isPasswordValid = passwordRequirements.every(r => r.met)
const doPasswordsMatch = password === confirmPassword && password.length > 0
const validateForm = (): string | null => {
if (!email.trim()) {
const validateForm = (
currentEmail: string,
currentPassword: string,
confirm: string
): string | null => {
if (!currentEmail.trim()) {
return 'Введіть email адресу'
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
if (!emailRegex.test(currentEmail)) {
return 'Введіть коректну email адресу'
}
if (!isPasswordValid) {
return 'Пароль не відповідає вимогам'
}
if (!doPasswordsMatch) {
if (currentPassword !== confirm || currentPassword.length === 0) {
return 'Паролі не співпадають'
}
return null
@@ -55,7 +60,23 @@ export default function RegisterPage() {
e.preventDefault()
setError('')
const validationError = validateForm()
const formData = new FormData(e.currentTarget as HTMLFormElement)
const fallbackEmail = (formData.get('email')?.toString() ?? '').trim()
const fallbackPassword = formData.get('password')?.toString() ?? ''
const fallbackConfirm = formData.get('confirmPassword')?.toString() ?? ''
const fallbackDisplayName = formData.get('displayName')?.toString() ?? ''
const currentEmail = email.trim() || fallbackEmail
const currentPassword = password || fallbackPassword
const currentConfirm = confirmPassword || fallbackConfirm
const currentDisplayName = displayName || fallbackDisplayName
if (!email && currentEmail) setEmail(currentEmail)
if (!password && currentPassword) setPassword(currentPassword)
if (!confirmPassword && currentConfirm) setConfirmPassword(currentConfirm)
if (!displayName && currentDisplayName) setDisplayName(currentDisplayName)
const validationError = validateForm(currentEmail, currentPassword, currentConfirm)
if (validationError) {
setError(validationError)
return
@@ -64,7 +85,9 @@ export default function RegisterPage() {
setLoading(true)
try {
await register(email, password, displayName || undefined)
await authRegister(currentEmail, currentPassword, currentDisplayName || undefined)
await authLogin(currentEmail, currentPassword)
await refreshUser()
router.push('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'Помилка реєстрації. Спробуйте ще раз.')
@@ -110,6 +133,7 @@ export default function RegisterPage() {
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="displayName"
name="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
@@ -134,6 +158,7 @@ export default function RegisterPage() {
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -158,6 +183,7 @@ export default function RegisterPage() {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -197,6 +223,7 @@ export default function RegisterPage() {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}

View File

@@ -0,0 +1,25 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
export async function POST(req: NextRequest) {
const body = await req.text()
const response = await fetch(`${INTERNAL_API_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': req.headers.get('content-type') || 'application/json',
},
body,
})
const text = await response.text()
return new Response(text, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') || 'application/json',
},
})
}

View File

@@ -0,0 +1,25 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
export async function POST(req: NextRequest) {
const body = await req.text()
const response = await fetch(`${INTERNAL_API_URL}/api/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': req.headers.get('content-type') || 'application/json',
},
body,
})
const text = await response.text()
return new Response(text, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') || 'application/json',
},
})
}

View File

@@ -0,0 +1,23 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
export async function GET(req: NextRequest) {
const response = await fetch(`${INTERNAL_API_URL}/api/auth/me`, {
method: 'GET',
headers: {
Authorization: req.headers.get('authorization') || '',
},
})
const text = await response.text()
return new Response(text, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') || 'application/json',
},
})
}

View File

@@ -0,0 +1,25 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
export async function POST(req: NextRequest) {
const body = await req.text()
const response = await fetch(`${INTERNAL_API_URL}/api/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': req.headers.get('content-type') || 'application/json',
},
body,
})
const text = await response.text()
return new Response(text, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') || 'application/json',
},
})
}

View File

@@ -0,0 +1,25 @@
import { NextRequest } from 'next/server'
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || 'http://127.0.0.1:8080'
export async function POST(req: NextRequest) {
const body = await req.text()
const response = await fetch(`${INTERNAL_API_URL}/api/auth/register`, {
method: 'POST',
headers: {
'Content-Type': req.headers.get('content-type') || 'application/json',
},
body,
})
const text = await response.text()
return new Response(text, {
status: response.status,
headers: {
'Content-Type': response.headers.get('content-type') || 'application/json',
},
})
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const CITY_SERVICE_URL = process.env.CITY_SERVICE_URL || "http://localhost:7001";
export async function GET(req: NextRequest) {
try {
const upstream = await fetch(`${CITY_SERVICE_URL}/city/map`, {
headers: {
accept: "application/json",
},
cache: "no-store",
});
if (!upstream.ok) {
console.error(`City map API error: ${upstream.status}`);
return NextResponse.json(
{ error: "Failed to fetch city map", status: upstream.status },
{ status: upstream.status }
);
}
const data = await upstream.json();
return NextResponse.json(data);
} catch (error) {
console.error("City map proxy error:", error);
return NextResponse.json(
{ error: "City service unavailable" },
{ status: 503 }
);
}
}

View File

@@ -3,11 +3,11 @@ import { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const PRESENCE_AGGREGATOR_URL = process.env.PRESENCE_AGGREGATOR_URL || "http://localhost:8085/presence/stream";
const PRESENCE_AGGREGATOR_URL = process.env.PRESENCE_AGGREGATOR_URL || "http://localhost:8085";
export async function GET(req: NextRequest) {
try {
const upstream = await fetch(PRESENCE_AGGREGATOR_URL, {
const upstream = await fetch(`${PRESENCE_AGGREGATOR_URL}/presence/stream`, {
headers: {
accept: "text/event-stream",
},
@@ -62,3 +62,4 @@ export async function GET(req: NextRequest) {
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const PRESENCE_AGGREGATOR_URL = process.env.PRESENCE_AGGREGATOR_URL || "http://localhost:8085";
export async function GET(req: NextRequest) {
try {
const upstream = await fetch(`${PRESENCE_AGGREGATOR_URL}/presence/summary`, {
headers: {
accept: "application/json",
},
cache: "no-store",
});
if (!upstream.ok) {
return NextResponse.json(
{ error: "Failed to connect to presence aggregator", status: upstream.status },
{ status: 502 }
);
}
const data = await upstream.json();
return NextResponse.json(data);
} catch (error) {
console.error("Presence summary proxy error:", error);
return NextResponse.json(
{ error: "Presence aggregator unavailable" },
{ status: 503 }
);
}
}

View File

@@ -2,15 +2,17 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Building2, Users, Star, MessageSquare, ArrowRight, Loader2 } from 'lucide-react'
import { Building2, Users, Star, MessageSquare, ArrowRight, Loader2, Bot, Map } from 'lucide-react'
import { api, CityRoom } from '@/lib/api'
import { cn } from '@/lib/utils'
import { useGlobalPresence } from '@/hooks/useGlobalPresence'
import { CityMap } from '@/components/city/CityMap'
export default function CityPage() {
const [rooms, setRooms] = useState<CityRoom[]>([])
const [loading, setLoading] = useState(true)
const { cityOnline, roomsPresence } = useGlobalPresence()
const [showMap, setShowMap] = useState(true)
const { cityOnline, roomsPresence, agents } = useGlobalPresence()
useEffect(() => {
async function fetchRooms() {
@@ -68,27 +70,65 @@ export default function CityPage() {
)}
</div>
{/* View Toggle */}
<div className="flex items-center gap-2 mb-6">
<button
onClick={() => setShowMap(true)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all",
showMap
? "bg-cyan-500/20 text-cyan-400 border border-cyan-500/30"
: "text-slate-400 hover:text-white hover:bg-white/5"
)}
>
<Map className="w-4 h-4" />
Мапа
</button>
<button
onClick={() => setShowMap(false)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl font-medium transition-all",
!showMap
? "bg-cyan-500/20 text-cyan-400 border border-cyan-500/30"
: "text-slate-400 hover:text-white hover:bg-white/5"
)}
>
<Building2 className="w-4 h-4" />
Список
</button>
</div>
{/* City Map View */}
{showMap && (
<div className="mb-8">
<CityMap />
</div>
)}
{/* Rooms Grid */}
{rooms.length === 0 ? (
<div className="glass-panel p-12 text-center">
<Building2 className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">
Кімнати не знайдено
</h2>
<p className="text-slate-400">
API недоступний або кімнати ще не створені
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{rooms.map((room) => (
<RoomCard
key={room.id}
room={room}
livePresence={roomsPresence[room.id]}
/>
))}
</div>
{!showMap && (
rooms.length === 0 ? (
<div className="glass-panel p-12 text-center">
<Building2 className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">
Кімнати не знайдено
</h2>
<p className="text-slate-400">
API недоступний або кімнати ще не створені
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{rooms.map((room) => (
<RoomCard
key={room.id}
room={room}
livePresence={roomsPresence[room.id]}
roomAgents={agents.filter(a => a.room_id === room.id)}
/>
))}
</div>
)
)}
{/* Stats Section */}
@@ -126,9 +166,10 @@ export default function CityPage() {
interface RoomCardProps {
room: CityRoom
livePresence?: { online: number; typing: number }
roomAgents?: Array<{ agent_id: string; display_name: string; status: string; color?: string }>
}
function RoomCard({ room, livePresence }: RoomCardProps) {
function RoomCard({ room, livePresence, roomAgents = [] }: RoomCardProps) {
// Use live presence if available, otherwise fallback to API data
const onlineCount = livePresence?.online ?? room.members_online
const typingCount = livePresence?.typing ?? 0
@@ -186,6 +227,34 @@ function RoomCard({ room, livePresence }: RoomCardProps) {
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all" />
</div>
{/* Agents in room */}
{roomAgents.length > 0 && (
<div className="mt-3 pt-3 border-t border-white/10">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-cyan-400" />
<span className="text-xs text-slate-400">Агенти:</span>
<div className="flex items-center gap-1">
{roomAgents.slice(0, 3).map((agent) => (
<span
key={agent.agent_id}
className={cn(
"px-2 py-0.5 text-xs rounded-full",
agent.status === 'online'
? "bg-green-500/20 text-green-400"
: "bg-orange-500/20 text-orange-400"
)}
>
{agent.display_name}
</span>
))}
{roomAgents.length > 3 && (
<span className="text-xs text-slate-500">+{roomAgents.length - 3}</span>
)}
</div>
</div>
</div>
)}
</Link>
)
}

View File

@@ -0,0 +1,250 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCityMap, CityMapRoom } from '@/hooks/useCityMap'
import { useGlobalPresence } from '@/hooks/useGlobalPresence'
import { cn } from '@/lib/utils'
import {
MessageSquare,
Zap,
FlaskConical,
Hammer,
HandMetal,
Users,
Bot,
Loader2
} from 'lucide-react'
import { AgentPresence } from '@/lib/global-presence'
// Icon mapping
const iconMap: Record<string, React.ElementType> = {
'message-square': MessageSquare,
'zap': Zap,
'flask-conical': FlaskConical,
'hammer': Hammer,
'hand-wave': HandMetal,
}
// Color mapping to Tailwind classes
const colorMap: Record<string, string> = {
cyan: 'from-cyan-500/20 to-cyan-600/10 border-cyan-500/30 hover:border-cyan-400/50',
green: 'from-green-500/20 to-green-600/10 border-green-500/30 hover:border-green-400/50',
orange: 'from-orange-500/20 to-orange-600/10 border-orange-500/30 hover:border-orange-400/50',
purple: 'from-purple-500/20 to-purple-600/10 border-purple-500/30 hover:border-purple-400/50',
yellow: 'from-yellow-500/20 to-yellow-600/10 border-yellow-500/30 hover:border-yellow-400/50',
blue: 'from-blue-500/20 to-blue-600/10 border-blue-500/30 hover:border-blue-400/50',
}
const textColorMap: Record<string, string> = {
cyan: 'text-cyan-400',
green: 'text-green-400',
orange: 'text-orange-400',
purple: 'text-purple-400',
yellow: 'text-yellow-400',
blue: 'text-blue-400',
}
interface RoomTileProps {
room: CityMapRoom
online: number
typing: number
agents: AgentPresence[]
cellSize: number
onClick: () => void
}
function RoomTile({ room, online, typing, agents, cellSize, onClick }: RoomTileProps) {
const Icon = iconMap[room.icon || 'message-square'] || MessageSquare
const colorClass = colorMap[room.color || 'cyan'] || colorMap.cyan
const textColor = textColorMap[room.color || 'cyan'] || textColorMap.cyan
// Calculate brightness based on online count
const brightness = Math.min(1, 0.3 + (online * 0.15))
return (
<button
onClick={onClick}
className={cn(
'absolute rounded-xl border transition-all duration-300',
'bg-gradient-to-br backdrop-blur-sm',
'hover:scale-[1.02] hover:shadow-lg cursor-pointer',
'flex flex-col items-center justify-center gap-1 p-2',
colorClass
)}
style={{
left: room.x * cellSize,
top: room.y * cellSize,
width: room.w * cellSize - 8,
height: room.h * cellSize - 8,
opacity: brightness,
}}
title={`${room.name} - ${online} online`}
>
<Icon className={cn('w-6 h-6', textColor)} />
<span className="text-xs font-medium text-white truncate max-w-full">
{room.name}
</span>
{/* Online count */}
<div className="flex items-center gap-1">
<Users className="w-3 h-3 text-slate-400" />
<span className={cn(
'text-xs font-bold',
online > 0 ? 'text-green-400' : 'text-slate-500'
)}>
{online}
</span>
{/* Typing indicator */}
{typing > 0 && (
<span className="text-xs text-cyan-400 animate-pulse">...</span>
)}
</div>
{/* Agent badges */}
{agents.length > 0 && (
<div className="flex items-center gap-1 mt-1">
{agents.slice(0, 3).map((agent) => (
<div
key={agent.agent_id}
className={cn(
'w-5 h-5 rounded-full flex items-center justify-center',
'bg-slate-800/80 border',
agent.status === 'online' ? 'border-green-500/50' : 'border-orange-500/50'
)}
title={`${agent.display_name} (${agent.status})`}
>
<Bot className={cn(
'w-3 h-3',
textColorMap[agent.color || 'cyan'] || 'text-cyan-400'
)} />
</div>
))}
{agents.length > 3 && (
<span className="text-xs text-slate-400">+{agents.length - 3}</span>
)}
</div>
)}
</button>
)
}
export function CityMap() {
const router = useRouter()
const { config, rooms, loading, error } = useCityMap()
const { cityOnline, roomsPresence, agents } = useGlobalPresence()
if (loading) {
return (
<div className="glass-panel p-8 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
<span className="ml-3 text-slate-400">Завантаження мапи...</span>
</div>
)
}
if (error) {
return (
<div className="glass-panel p-8 text-center">
<p className="text-red-400">Помилка завантаження мапи: {error}</p>
</div>
)
}
if (!config || rooms.length === 0) {
return (
<div className="glass-panel p-8 text-center">
<p className="text-slate-400">Мапа міста порожня</p>
</div>
)
}
const cellSize = config.cell_size
const mapWidth = config.grid_width * cellSize
const mapHeight = config.grid_height * cellSize
// Count online agents
const onlineAgents = agents.filter(a => a.status === 'online' || a.status === 'busy')
return (
<div className="glass-panel p-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Мапа Міста</h2>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1">
<Users className="w-4 h-4 text-green-400" />
<span className="text-green-400 font-medium">{cityOnline}</span>
<span className="text-slate-400">онлайн</span>
</div>
<div className="flex items-center gap-1">
<Bot className="w-4 h-4 text-cyan-400" />
<span className="text-cyan-400 font-medium">{onlineAgents.length}</span>
<span className="text-slate-400">агентів</span>
</div>
</div>
</div>
{/* Map container */}
<div
className="relative bg-slate-900/50 rounded-xl overflow-hidden"
style={{
width: mapWidth,
height: mapHeight,
maxWidth: '100%',
}}
>
{/* Grid background */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `
linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px)
`,
backgroundSize: `${cellSize}px ${cellSize}px`,
}}
/>
{/* Room tiles */}
{rooms.map((room) => {
const presence = roomsPresence[room.id]
const roomAgents = agents.filter(a => a.room_id === room.id)
return (
<RoomTile
key={room.id}
room={room}
online={presence?.online || 0}
typing={presence?.typing || 0}
agents={roomAgents}
cellSize={cellSize}
onClick={() => router.push(`/city/${room.slug}`)}
/>
)
})}
</div>
{/* Legend */}
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-400">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gradient-to-br from-cyan-500/40 to-cyan-600/20" />
<span>Public</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gradient-to-br from-green-500/40 to-green-600/20" />
<span>Social</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gradient-to-br from-purple-500/40 to-purple-600/20" />
<span>Science</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gradient-to-br from-orange-500/40 to-orange-600/20" />
<span>Builders</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState, useEffect, useCallback } from "react";
export interface CityMapRoom {
id: string;
slug: string;
name: string;
description?: string;
room_type: string;
zone: string;
icon?: string;
color?: string;
x: number;
y: number;
w: number;
h: number;
matrix_room_id?: string;
}
export interface CityMapConfig {
grid_width: number;
grid_height: number;
cell_size: number;
background_url?: string;
}
export interface CityMapData {
config: CityMapConfig;
rooms: CityMapRoom[];
}
export function useCityMap() {
const [data, setData] = useState<CityMapData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchMap = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch("/api/city/map");
if (!res.ok) {
throw new Error(`Failed to fetch city map: ${res.status}`);
}
const mapData: CityMapData = await res.json();
setData(mapData);
} catch (err) {
console.error("Error fetching city map:", err);
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchMap();
}, [fetchMap]);
return {
config: data?.config ?? null,
rooms: data?.rooms ?? [],
loading,
error,
refetch: fetchMap,
};
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { globalPresenceClient, RoomPresence } from '@/lib/global-presence'
import { globalPresenceClient, RoomPresence, AgentPresence } from '@/lib/global-presence'
/**
* Hook for subscribing to global room presence updates via SSE
@@ -9,17 +9,19 @@ import { globalPresenceClient, RoomPresence } from '@/lib/global-presence'
export function useGlobalPresence() {
const [cityOnline, setCityOnline] = useState(0)
const [roomsPresence, setRoomsPresence] = useState<Record<string, RoomPresence>>({})
const [agents, setAgents] = useState<AgentPresence[]>([])
useEffect(() => {
const unsubscribe = globalPresenceClient.subscribe((newCityOnline, newRoomsPresence) => {
const unsubscribe = globalPresenceClient.subscribe((newCityOnline, newRoomsPresence, newAgents) => {
setCityOnline(newCityOnline)
setRoomsPresence(newRoomsPresence)
setAgents(newAgents)
})
return unsubscribe
}, [])
return { cityOnline, roomsPresence }
return { cityOnline, roomsPresence, agents }
}
/**
@@ -30,3 +32,11 @@ export function useRoomPresence(roomId: string): RoomPresence | null {
return roomsPresence[roomId] || null
}
/**
* Hook for getting agents in a specific room
*/
export function useRoomAgents(roomId: string): AgentPresence[] {
const { agents } = useGlobalPresence()
return agents.filter(a => a.room_id === roomId)
}

View File

@@ -148,3 +148,4 @@ export function usePresenceHeartbeat({
}, [enabled, matrixUserId, accessToken, sendPresence])
}

View File

@@ -106,8 +106,15 @@ export async function login(email: string, password: string): Promise<LoginRespo
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.detail || 'Login failed')
let errorDetail = 'Login failed'
try {
const error = await response.json()
errorDetail = error.detail || error.message || errorDetail
} catch {
// ignore JSON parse errors
}
throw new Error(errorDetail)
}
const data: LoginResponse = await response.json()

View File

@@ -4,16 +4,27 @@
* Connects to /api/presence/stream for real-time room presence updates via SSE
*/
export interface AgentPresence {
agent_id: string;
display_name: string;
kind: string;
status: string;
room_id?: string;
color?: string;
}
export interface RoomPresence {
room_id: string;
matrix_room_id?: string;
online: number;
typing: number;
agents?: AgentPresence[];
}
export interface CityPresence {
online_total: number;
rooms_online: number;
agents_online?: number;
}
export interface PresenceEvent {
@@ -21,17 +32,21 @@ export interface PresenceEvent {
timestamp: string;
city: CityPresence;
rooms: RoomPresence[];
agents?: AgentPresence[];
}
export type PresenceCallback = (
cityOnline: number,
roomsPresence: Record<string, RoomPresence>
roomsPresence: Record<string, RoomPresence>,
agents: AgentPresence[]
) => void;
class GlobalPresenceClient {
private eventSource: EventSource | null = null;
private cityOnline: number = 0;
private agentsOnline: number = 0;
private roomsPresence: Record<string, RoomPresence> = {};
private agents: AgentPresence[] = [];
private listeners: Set<PresenceCallback> = new Set();
private reconnectTimeout: NodeJS.Timeout | null = null;
private isConnecting = false;
@@ -99,7 +114,7 @@ class GlobalPresenceClient {
// Send current state immediately
if (this.cityOnline > 0 || Object.keys(this.roomsPresence).length > 0) {
callback(this.cityOnline, this.roomsPresence);
callback(this.cityOnline, this.roomsPresence, this.agents);
}
// Connect if not connected
@@ -115,6 +130,18 @@ class GlobalPresenceClient {
};
}
getAgentsOnline(): number {
return this.agentsOnline;
}
getAllAgents(): AgentPresence[] {
return [...this.agents];
}
getAgentsInRoom(roomId: string): AgentPresence[] {
return this.agents.filter(a => a.room_id === roomId);
}
getCityOnline(): number {
return this.cityOnline;
}
@@ -132,6 +159,7 @@ class GlobalPresenceClient {
// Update city stats
this.cityOnline = data.city?.online_total || 0;
this.agentsOnline = data.city?.agents_online || 0;
// Update rooms
const newRoomsPresence: Record<string, RoomPresence> = {};
@@ -140,13 +168,16 @@ class GlobalPresenceClient {
}
this.roomsPresence = newRoomsPresence;
// Update agents
this.agents = data.agents || [];
this.notifyListeners();
}
private notifyListeners(): void {
for (const callback of this.listeners) {
try {
callback(this.cityOnline, this.roomsPresence);
callback(this.cityOnline, this.roomsPresence, this.agents);
} catch (e) {
console.error('[GlobalPresence] Listener error:', e);
}