feat: District Portals Frontend

Pages:
- /districts - list of all districts with cards
- /districts/[slug] - district detail page
- /soul, /greenfood, /energy-union - shortcut redirects

UI Features:
- District-specific colors and icons
- Lead agent + core team display
- District rooms list with Matrix status
- District nodes list
- Chat widget for lobby room
- Stats (agents, rooms, nodes count)
This commit is contained in:
Apple
2025-11-30 11:40:30 -08:00
parent 0fd05f678a
commit c9d7681627
5 changed files with 588 additions and 0 deletions

View File

@@ -0,0 +1,374 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import {
ArrowLeft, Building2, Bot, Users, MessageSquare,
Home, Zap, Leaf, Heart, Server, Activity
} from 'lucide-react'
import { CityChatWidget } from '@/components/city/CityChatWidget'
export const dynamic = 'force-dynamic'
interface DistrictAgent {
id: string
name: string
kind: string | null
status: string | null
avatar_url: string | null
role: string | null
is_core?: boolean
}
interface DistrictRoom {
id: string
slug: string
name: string
description: string | null
matrix_room_id: string | null
room_role: string | null
is_public: boolean
}
interface DistrictNode {
id: string
name: string
kind: string | null
status: string | null
location: string | null
}
interface DistrictDetail {
district: {
id: string
slug: string
name: string
description: string | null
dao_type: string
}
lead_agent: DistrictAgent | null
core_team: DistrictAgent[]
agents: DistrictAgent[]
rooms: DistrictRoom[]
nodes: DistrictNode[]
stats: {
agents_count: number
rooms_count: number
nodes_count: number
}
}
interface PageProps {
params: Promise<{ slug: string }>
}
const districtIcons: Record<string, typeof Building2> = {
'soul': Heart,
'greenfood': Leaf,
'energy-union': Zap,
}
const districtColors: Record<string, { bg: string; text: string; border: string }> = {
'soul': { bg: 'from-purple-500/20 to-pink-600/20', text: 'text-purple-400', border: 'border-purple-500/30' },
'greenfood': { bg: 'from-emerald-500/20 to-green-600/20', text: 'text-emerald-400', border: 'border-emerald-500/30' },
'energy-union': { bg: 'from-amber-500/20 to-orange-600/20', text: 'text-amber-400', border: 'border-amber-500/30' },
}
async function getDistrict(slug: string): Promise<DistrictDetail | null> {
try {
const apiUrl = process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001'
const res = await fetch(`${apiUrl}/api/v1/districts/${slug}`, {
cache: 'no-store'
})
if (!res.ok) return null
return res.json()
} catch (error) {
console.error('Failed to fetch district:', error)
return null
}
}
export default async function DistrictPage({ params }: PageProps) {
const { slug } = await params
const data = await getDistrict(slug)
if (!data) {
notFound()
}
const { district, lead_agent, core_team, agents, rooms, nodes, stats } = data
const Icon = districtIcons[slug] || Building2
const colors = districtColors[slug] || { bg: 'from-cyan-500/20 to-blue-600/20', text: 'text-cyan-400', border: 'border-cyan-500/30' }
// Find lobby room for chat
const lobbyRoom = rooms.find(r => r.slug.includes('lobby'))
return (
<div className="min-h-screen">
{/* Header */}
<div className={`px-4 py-8 border-b border-white/5 bg-gradient-to-br ${colors.bg}`}>
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm mb-6">
<Link href="/" className="text-slate-400 hover:text-white transition-colors">
<Home className="w-4 h-4" />
</Link>
<span className="text-slate-600">/</span>
<Link href="/districts" className="text-slate-400 hover:text-white transition-colors">
Districts
</Link>
<span className="text-slate-600">/</span>
<span className={colors.text}>{district.name}</span>
</nav>
{/* Title */}
<div className="flex items-start gap-4">
<div className={`p-4 rounded-2xl bg-gradient-to-br ${colors.bg} border ${colors.border}`}>
<Icon className={`w-10 h-10 ${colors.text}`} />
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{district.name}</h1>
<span className={`px-3 py-1 text-xs font-medium rounded-full bg-white/10 ${colors.text}`}>
District
</span>
</div>
<p className="text-slate-400 max-w-2xl">
{district.description || 'District платформа DAARION.city'}
</p>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-6 mt-6">
<div className="flex items-center gap-2 text-sm">
<Bot className="w-4 h-4 text-slate-400" />
<span className="text-white font-medium">{stats.agents_count}</span>
<span className="text-slate-400">агентів</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MessageSquare className="w-4 h-4 text-slate-400" />
<span className="text-white font-medium">{stats.rooms_count}</span>
<span className="text-slate-400">кімнат</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Server className="w-4 h-4 text-slate-400" />
<span className="text-white font-medium">{stats.nodes_count}</span>
<span className="text-slate-400">нод</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Chat */}
<div className="lg:col-span-2 space-y-6">
{/* Chat Widget */}
{lobbyRoom && (
<div className="glass-panel overflow-hidden">
<div className="p-4 border-b border-white/10">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<MessageSquare className={`w-5 h-5 ${colors.text}`} />
{lobbyRoom.name}
</h3>
<p className="text-sm text-slate-400 mt-1">
Головна кімната District-а
</p>
</div>
<CityChatWidget
roomSlug={lobbyRoom.slug}
mode="embedded"
className="h-[500px]"
/>
</div>
)}
{/* Rooms */}
{rooms.length > 0 && (
<div className="glass-panel p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Building2 className={`w-5 h-5 ${colors.text}`} />
Кімнати District-а
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{rooms.map((room) => (
<Link
key={room.id}
href={`/city/${room.slug}`}
className="p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors group"
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-white group-hover:text-cyan-400 transition-colors">
{room.name}
</p>
{room.room_role && (
<p className="text-xs text-slate-400 capitalize mt-1">
{room.room_role}
</p>
)}
</div>
{room.matrix_room_id && (
<span className="w-2 h-2 rounded-full bg-emerald-400" title="Matrix connected" />
)}
</div>
</Link>
))}
</div>
</div>
)}
{/* Nodes */}
{nodes.length > 0 && (
<div className="glass-panel p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Server className={`w-5 h-5 ${colors.text}`} />
Ноди District-а
</h3>
<div className="space-y-3">
{nodes.map((node) => (
<Link
key={node.id}
href={`/nodes/${node.id}`}
className="flex items-center justify-between p-4 rounded-xl bg-white/5 hover:bg-white/10 transition-colors group"
>
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-slate-400" />
<div>
<p className="font-medium text-white group-hover:text-cyan-400 transition-colors">
{node.name}
</p>
<p className="text-xs text-slate-400">
{node.kind} {node.location || 'Unknown location'}
</p>
</div>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
node.status === 'online'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-slate-500/20 text-slate-400'
}`}>
{node.status || 'unknown'}
</span>
</Link>
))}
</div>
</div>
)}
</div>
{/* Right Column - Agents */}
<div className="space-y-6">
{/* Lead Agent */}
{lead_agent && (
<div className="glass-panel p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Bot className={`w-5 h-5 ${colors.text}`} />
Lead Agent
</h3>
<Link
href={`/agents/${lead_agent.id}`}
className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-white/5 to-white/10 hover:from-white/10 hover:to-white/15 transition-colors group"
>
<div className="relative">
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${colors.bg} flex items-center justify-center`}>
{lead_agent.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={lead_agent.avatar_url}
alt={lead_agent.name}
className="w-full h-full rounded-xl object-cover"
/>
) : (
<Bot className={`w-7 h-7 ${colors.text}`} />
)}
</div>
<span className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-slate-900 ${
lead_agent.status === 'active' ? 'bg-emerald-400' : 'bg-slate-500'
}`} />
</div>
<div>
<p className="font-semibold text-white group-hover:text-cyan-400 transition-colors">
{lead_agent.name}
</p>
<p className="text-sm text-slate-400 capitalize">
{lead_agent.kind || 'Agent'} District Lead
</p>
</div>
</Link>
</div>
)}
{/* Core Team */}
{core_team.length > 0 && (
<div className="glass-panel p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Users className={`w-5 h-5 ${colors.text}`} />
Core Team
</h3>
<div className="space-y-3">
{core_team.map((agent) => (
<Link
key={agent.id}
href={`/agents/${agent.id}`}
className="flex items-center gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition-colors group"
>
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-violet-500/30 to-purple-600/30 flex items-center justify-center">
<Bot className="w-5 h-5 text-violet-400" />
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-slate-900 ${
agent.status === 'active' ? 'bg-emerald-400' : 'bg-slate-500'
}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate group-hover:text-cyan-400 transition-colors">
{agent.name}
</p>
<p className="text-xs text-slate-400 capitalize">
{agent.kind || 'agent'}
</p>
</div>
</Link>
))}
</div>
</div>
)}
{/* All Agents */}
{agents.length > 0 && (
<div className="glass-panel p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Activity className={`w-5 h-5 ${colors.text}`} />
Всі агенти ({agents.length})
</h3>
<div className="space-y-2">
{agents.map((agent) => (
<Link
key={agent.id}
href={`/agents/${agent.id}`}
className="flex items-center justify-between p-2 rounded-lg hover:bg-white/5 transition-colors group"
>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
agent.status === 'active' ? 'bg-emerald-400' : 'bg-slate-500'
}`} />
<span className="text-sm text-white group-hover:text-cyan-400 transition-colors">
{agent.name}
</span>
</div>
<span className="text-xs text-slate-500 capitalize">
{agent.role || agent.kind}
</span>
</Link>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Building2, Users, Bot, ArrowRight, Loader2, MapPin, Zap, Leaf, Heart } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DistrictSummary {
id: string
slug: string
name: string
description: string | null
dao_type: string
lead_agent: {
id: string
name: string
avatar_url: string | null
} | null
rooms_count: number
rooms: { id: string; slug: string; name: string }[]
}
const districtIcons: Record<string, typeof Building2> = {
'soul': Heart,
'greenfood': Leaf,
'energy-union': Zap,
}
const districtColors: Record<string, string> = {
'soul': 'from-purple-500/20 to-pink-600/20 border-purple-500/30',
'greenfood': 'from-emerald-500/20 to-green-600/20 border-emerald-500/30',
'energy-union': 'from-amber-500/20 to-orange-600/20 border-amber-500/30',
}
export default function DistrictsPage() {
const [districts, setDistricts] = useState<DistrictSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function fetchDistricts() {
try {
const res = await fetch('/api/v1/districts')
if (!res.ok) throw new Error('Failed to fetch districts')
const data = await res.json()
setDistricts(data)
} catch (err) {
console.error('Failed to fetch districts:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
fetchDistricts()
}, [])
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-red-400 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-cyan-500/20 text-cyan-400 rounded-lg hover:bg-cyan-500/30"
>
Спробувати знову
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-600/20">
<MapPin className="w-8 h-8 text-violet-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">District Portals</h1>
<p className="text-slate-400">Платформи та спільноти DAARION.city</p>
</div>
</div>
</div>
{/* Districts Grid */}
{districts.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">
District-и не знайдено
</h2>
<p className="text-slate-400">
Поки що немає активних District-ів
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{districts.map((district) => (
<DistrictCard key={district.id} district={district} />
))}
</div>
)}
{/* Info Section */}
<div className="mt-12 glass-panel p-6">
<h3 className="text-lg font-semibold text-white mb-4">Що таке District?</h3>
<p className="text-slate-400 text-sm leading-relaxed">
District це платформа або тематична спільнота всередині DAARION.city.
Кожен District має свого Lead Agent-а, команду агентів, власні кімнати
та може містити MicroDAO. Districts є точками входу для користувачів
у відповідні екосистеми: wellness (SOUL), food systems (GREENFOOD),
energy/compute (ENERGY UNION).
</p>
</div>
</div>
</div>
)
}
function DistrictCard({ district }: { district: DistrictSummary }) {
const Icon = districtIcons[district.slug] || Building2
const colorClass = districtColors[district.slug] || 'from-cyan-500/20 to-blue-600/20 border-cyan-500/30'
return (
<Link
href={`/districts/${district.slug}`}
className={cn(
"glass-panel p-6 group block transition-all hover:scale-[1.02]",
"bg-gradient-to-br border",
colorClass
)}
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={cn(
"p-3 rounded-xl bg-gradient-to-br",
colorClass
)}>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-white group-hover:text-cyan-400 transition-colors">
{district.name}
</h3>
<span className="text-xs text-slate-400 uppercase tracking-wider">
District
</span>
</div>
</div>
</div>
{/* Description */}
<p className="text-sm text-slate-400 mb-4 line-clamp-2">
{district.description || 'Без опису'}
</p>
{/* Lead Agent */}
{district.lead_agent && (
<div className="flex items-center gap-2 mb-4 p-2 rounded-lg bg-white/5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500/30 to-purple-600/30 flex items-center justify-center">
<Bot className="w-4 h-4 text-violet-400" />
</div>
<div>
<p className="text-sm text-white font-medium">{district.lead_agent.name}</p>
<p className="text-xs text-slate-400">Lead Agent</p>
</div>
</div>
)}
{/* Stats */}
<div className="flex items-center justify-between pt-4 border-t border-white/10">
<div className="flex items-center gap-4 text-sm text-slate-400">
<span className="flex items-center gap-1">
<Building2 className="w-4 h-4" />
{district.rooms_count} кімнат
</span>
</div>
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all" />
</div>
</Link>
)
}

View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function EnergyUnionPage() {
redirect('/districts/energy-union')
}

View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function GreenfoodPage() {
redirect('/districts/greenfood')
}

View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
export default function SoulPage() {
redirect('/districts/soul')
}