feat: Add MicroDAO Dashboard with activity feed and statistics
- Add microdao_activity table for news/updates/events - Add statistics columns to microdaos table - Implement dashboard API endpoints - Create UI components (HeaderCard, ActivitySection, TeamSection) - Add seed data for DAARION DAO - Update backend models and repositories - Add frontend types and API client
This commit is contained in:
61
apps/web/src/app/api/microdao/[slug]/activity/route.ts
Normal file
61
apps/web/src/app/api/microdao/[slug]/activity/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const CITY_API_BASE_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const limit = searchParams.get("limit") || "20";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${CITY_API_BASE_URL}/city/microdao/${encodeURIComponent(slug)}/activity?limit=${limit}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MicroDAO activity proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch MicroDAO activity" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${CITY_API_BASE_URL}/city/microdao/${encodeURIComponent(slug)}/activity`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MicroDAO activity create proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create MicroDAO activity" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
31
apps/web/src/app/api/microdao/[slug]/dashboard/route.ts
Normal file
31
apps/web/src/app/api/microdao/[slug]/dashboard/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const CITY_API_BASE_URL = process.env.INTERNAL_API_URL || process.env.CITY_API_BASE_URL || "http://daarion-city-service:7001";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${CITY_API_BASE_URL}/city/microdao/${encodeURIComponent(slug)}/dashboard`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("MicroDAO dashboard proxy error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch MicroDAO dashboard" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
107
apps/web/src/components/microdao/MicrodaoActivitySection.tsx
Normal file
107
apps/web/src/components/microdao/MicrodaoActivitySection.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MicrodaoActivity, MicrodaoSummary } from "@/lib/types/microdao";
|
||||||
|
|
||||||
|
interface MicrodaoActivitySectionProps {
|
||||||
|
activity: MicrodaoActivity[];
|
||||||
|
microdao: MicrodaoSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicrodaoActivitySection({ activity, microdao }: MicrodaoActivitySectionProps) {
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "щойно";
|
||||||
|
if (diffMins < 60) return `${diffMins} хв тому`;
|
||||||
|
if (diffHours < 24) return `${diffHours} год тому`;
|
||||||
|
if (diffDays < 7) return `${diffDays} дн тому`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString("uk-UA", { day: "numeric", month: "short" });
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKindLabel = (kind: string) => {
|
||||||
|
switch (kind) {
|
||||||
|
case "post":
|
||||||
|
return "Новина";
|
||||||
|
case "event":
|
||||||
|
return "Подія";
|
||||||
|
case "update":
|
||||||
|
return "Оновлення";
|
||||||
|
default:
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKindColor = (kind: string) => {
|
||||||
|
switch (kind) {
|
||||||
|
case "post":
|
||||||
|
return "bg-blue-500/10 text-blue-300 border-blue-500/30";
|
||||||
|
case "event":
|
||||||
|
return "bg-purple-500/10 text-purple-300 border-purple-500/30";
|
||||||
|
case "update":
|
||||||
|
return "bg-green-500/10 text-green-300 border-green-500/30";
|
||||||
|
default:
|
||||||
|
return "bg-slate-500/10 text-slate-300 border-slate-500/30";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-6 bg-cyan-400 rounded-full" />
|
||||||
|
Новини {microdao.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-500 py-8 text-center">
|
||||||
|
Поки що немає новин
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activity.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="bg-slate-900/50 border border-slate-700/30 rounded-lg p-4 hover:border-slate-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-medium border ${getKindColor(item.kind)}`}>
|
||||||
|
{getKindLabel(item.kind)}
|
||||||
|
</span>
|
||||||
|
{item.title && (
|
||||||
|
<h3 className="text-sm font-semibold text-slate-200">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500 whitespace-nowrap">
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-300 leading-relaxed line-clamp-3">
|
||||||
|
{item.body}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{item.author_name && (
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
Автор: {item.author_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
137
apps/web/src/components/microdao/MicrodaoHeaderCard.tsx
Normal file
137
apps/web/src/components/microdao/MicrodaoHeaderCard.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MessageCircle, Plus, Users, MessageSquare, Bot } from "lucide-react";
|
||||||
|
import { MicrodaoDashboard } from "@/lib/types/microdao";
|
||||||
|
import { normalizeAssetUrl } from "@/lib/utils/assetUrl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface MicrodaoHeaderCardProps {
|
||||||
|
dashboard: MicrodaoDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicrodaoHeaderCard({ dashboard }: MicrodaoHeaderCardProps) {
|
||||||
|
const { microdao, stats } = dashboard;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-3xl border border-white/10 bg-gradient-to-br from-sky-950/50 via-slate-900 to-black p-6 md:p-8 space-y-6 relative overflow-hidden shadow-2xl shadow-sky-900/10"
|
||||||
|
style={microdao.banner_url ? {
|
||||||
|
backgroundImage: `linear-gradient(to bottom, rgba(15, 23, 42, 0.85), rgba(15, 23, 42, 0.95)), url(${normalizeAssetUrl(microdao.banner_url)})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
|
{/* Background pattern */}
|
||||||
|
{!microdao.banner_url && (
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] opacity-20" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col gap-6 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-5 max-w-3xl">
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-[11px] uppercase tracking-wider font-semibold border ${
|
||||||
|
microdao.is_platform
|
||||||
|
? "border-amber-500/40 text-amber-300 bg-amber-500/10"
|
||||||
|
: "border-cyan-400/40 text-cyan-300 bg-cyan-500/10"
|
||||||
|
}`}>
|
||||||
|
{microdao.is_platform ? "Platform District" : "MicroDAO"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{microdao.district && (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[11px] uppercase tracking-wider font-medium border border-white/10 text-white/60 bg-white/5">
|
||||||
|
{microdao.district}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-[11px] font-medium border flex items-center gap-1.5 ${
|
||||||
|
microdao.is_active
|
||||||
|
? "border-emerald-500/30 text-emerald-400 bg-emerald-500/5"
|
||||||
|
: "border-slate-600 text-slate-400 bg-slate-800"
|
||||||
|
}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${microdao.is_active ? 'bg-emerald-400' : 'bg-slate-500'}`} />
|
||||||
|
{microdao.is_active ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & Description with Logo */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Logo */}
|
||||||
|
{normalizeAssetUrl(microdao.logo_url) && (
|
||||||
|
<img
|
||||||
|
src={normalizeAssetUrl(microdao.logo_url)!}
|
||||||
|
alt={microdao.name}
|
||||||
|
className="w-16 h-16 md:w-20 md:h-20 rounded-2xl object-cover bg-slate-700/50 border border-white/10 shadow-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h1 className="text-3xl md:text-5xl font-bold text-white tracking-tight leading-tight">
|
||||||
|
{microdao.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{microdao.description && (
|
||||||
|
<p className="text-base md:text-lg text-slate-300 leading-relaxed max-w-2xl">
|
||||||
|
{microdao.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Stats */}
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
|
||||||
|
<MessageSquare className="w-4 h-4 text-white/60" />
|
||||||
|
<span className="text-sm text-white/80">{stats.rooms_count} Кімнат</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
|
||||||
|
<Users className="w-4 h-4 text-white/60" />
|
||||||
|
<span className="text-sm text-white/80">{stats.citizens_count} Громадян</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10">
|
||||||
|
<Bot className="w-4 h-4 text-white/60" />
|
||||||
|
<span className="text-sm text-white/80">{stats.agents_count} Агентів</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<Link href="/city?room=city-lobby">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 text-cyan-300"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-4 h-4 mr-2" />
|
||||||
|
Поспілкуватися з DAARWIZZ
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<a href="#microdao-rooms">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/5 hover:bg-white/10 border-white/20 text-white/80"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Створити кімнату
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo (right side) */}
|
||||||
|
<div className="hidden md:flex flex-col gap-3">
|
||||||
|
<div className="w-24 h-24 rounded-2xl bg-slate-800 border border-white/10 flex items-center justify-center overflow-hidden shadow-xl">
|
||||||
|
{normalizeAssetUrl(microdao.logo_url) ? (
|
||||||
|
<img
|
||||||
|
src={normalizeAssetUrl(microdao.logo_url)!}
|
||||||
|
alt={microdao.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 text-slate-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
24
apps/web/src/components/microdao/MicrodaoProjectsSection.tsx
Normal file
24
apps/web/src/components/microdao/MicrodaoProjectsSection.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MicrodaoSummary } from "@/lib/types/microdao";
|
||||||
|
import { FolderKanban } from "lucide-react";
|
||||||
|
|
||||||
|
interface MicrodaoProjectsSectionProps {
|
||||||
|
microdao: MicrodaoSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicrodaoProjectsSection({ microdao }: MicrodaoProjectsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||||
|
<FolderKanban className="w-5 h-5 text-cyan-400" />
|
||||||
|
Проєкти DAO
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="text-sm text-slate-500 py-8 text-center">
|
||||||
|
Скоро тут будуть проєкти MicroDAO
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
24
apps/web/src/components/microdao/MicrodaoTasksSection.tsx
Normal file
24
apps/web/src/components/microdao/MicrodaoTasksSection.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MicrodaoSummary } from "@/lib/types/microdao";
|
||||||
|
import { CheckSquare } from "lucide-react";
|
||||||
|
|
||||||
|
interface MicrodaoTasksSectionProps {
|
||||||
|
microdao: MicrodaoSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicrodaoTasksSection({ microdao }: MicrodaoTasksSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||||
|
<CheckSquare className="w-5 h-5 text-cyan-400" />
|
||||||
|
Задачі DAO
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="text-sm text-slate-500 py-8 text-center">
|
||||||
|
Скоро тут будуть задачі MicroDAO
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
78
apps/web/src/components/microdao/MicrodaoTeamSection.tsx
Normal file
78
apps/web/src/components/microdao/MicrodaoTeamSection.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PublicCitizenSummary } from "@/lib/types/microdao";
|
||||||
|
import { normalizeAssetUrl } from "@/lib/utils/assetUrl";
|
||||||
|
import { Users, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface MicrodaoTeamSectionProps {
|
||||||
|
citizens: PublicCitizenSummary[];
|
||||||
|
microdaoSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicrodaoTeamSection({ citizens, microdaoSlug }: MicrodaoTeamSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="bg-slate-800/30 border border-slate-700/50 rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100 flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-cyan-400" />
|
||||||
|
Команда
|
||||||
|
<span className="text-sm font-normal text-slate-500">({citizens.length})</span>
|
||||||
|
</h2>
|
||||||
|
{microdaoSlug && (
|
||||||
|
<Link
|
||||||
|
href={`/citizens${microdaoSlug ? `?microdao=${microdaoSlug}` : ''}`}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Всі громадяни
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{citizens.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-500 py-4 text-center">
|
||||||
|
Немає громадян у цьому MicroDAO
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{citizens.slice(0, 6).map((citizen) => (
|
||||||
|
<Link
|
||||||
|
key={citizen.slug || citizen.display_name}
|
||||||
|
href={citizen.slug ? `/citizens/${citizen.slug}` : '#'}
|
||||||
|
className="bg-slate-900/50 border border-slate-700/30 rounded-lg p-3 hover:border-cyan-500/40 transition-colors flex items-center gap-3"
|
||||||
|
>
|
||||||
|
{citizen.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={normalizeAssetUrl(citizen.avatar_url)!}
|
||||||
|
alt={citizen.display_name}
|
||||||
|
className="w-10 h-10 rounded-lg object-cover bg-slate-700/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-700/50 flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-200 truncate">
|
||||||
|
{citizen.display_name}
|
||||||
|
</p>
|
||||||
|
{citizen.public_title && (
|
||||||
|
<p className="text-xs text-slate-500 truncate">
|
||||||
|
{citizen.public_title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{citizen.district && (
|
||||||
|
<p className="text-[10px] text-slate-600 mt-1">
|
||||||
|
{citizen.district}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import type { MicrodaoSummary, MicrodaoDetail, MicrodaoRoomsList, CityRoomSummary } from '@/lib/types/microdao';
|
import type { MicrodaoSummary, MicrodaoDetail, MicrodaoRoomsList, CityRoomSummary, MicrodaoDashboard } from '@/lib/types/microdao';
|
||||||
|
import { fetchMicrodaoDashboard } from '@/lib/api/microdao';
|
||||||
|
|
||||||
interface UseMicrodaoListOptions {
|
interface UseMicrodaoListOptions {
|
||||||
district?: string;
|
district?: string;
|
||||||
@@ -302,3 +303,60 @@ export function useMicrodaoAgents(
|
|||||||
mutate: fetchData,
|
mutate: fetchData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MicroDAO Dashboard Hook (TASK_PHASE_MICRODAO_DASHBOARD_v1)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface UseMicrodaoDashboardOptions {
|
||||||
|
refreshInterval?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseMicrodaoDashboardResult {
|
||||||
|
dashboard: MicrodaoDashboard | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
mutate: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMicrodaoDashboard(
|
||||||
|
slug: string | undefined,
|
||||||
|
options: UseMicrodaoDashboardOptions = {}
|
||||||
|
): UseMicrodaoDashboardResult {
|
||||||
|
const { refreshInterval = 60000, enabled = true } = options;
|
||||||
|
|
||||||
|
const [dashboard, setDashboard] = useState<MicrodaoDashboard | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!enabled || !slug) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await fetchMicrodaoDashboard(slug);
|
||||||
|
setDashboard(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to fetch dashboard'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
if (refreshInterval > 0) {
|
||||||
|
const interval = setInterval(fetchData, refreshInterval);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [fetchData, refreshInterval]);
|
||||||
|
|
||||||
|
const mutate = useCallback(async () => {
|
||||||
|
await fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return { dashboard, isLoading, error, mutate };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
* MicroDAO API Client (Task 029)
|
* MicroDAO API Client (Task 029)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MicrodaoOption, CityRoomSummary } from "@/lib/types/microdao";
|
import {
|
||||||
|
MicrodaoOption,
|
||||||
|
CityRoomSummary,
|
||||||
|
MicrodaoDashboard,
|
||||||
|
MicrodaoActivity,
|
||||||
|
CreateMicrodaoActivity
|
||||||
|
} from "@/lib/types/microdao";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|||||||
@@ -177,3 +177,47 @@ export interface AgentMicrodaoMembership {
|
|||||||
is_core: boolean;
|
is_core: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MicroDAO Activity & Dashboard (TASK_PHASE_MICRODAO_DASHBOARD_v1)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type MicrodaoActivityKind = "post" | "event" | "update";
|
||||||
|
|
||||||
|
export interface MicrodaoActivity {
|
||||||
|
id: string;
|
||||||
|
microdao_slug: string;
|
||||||
|
kind: MicrodaoActivityKind;
|
||||||
|
title?: string | null;
|
||||||
|
body: string;
|
||||||
|
author_agent_id?: string | null;
|
||||||
|
author_name?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMicrodaoActivity {
|
||||||
|
kind: MicrodaoActivityKind;
|
||||||
|
title?: string | null;
|
||||||
|
body: string;
|
||||||
|
author_agent_id?: string | null;
|
||||||
|
author_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MicrodaoStats {
|
||||||
|
rooms_count: number;
|
||||||
|
citizens_count: number;
|
||||||
|
agents_count: number;
|
||||||
|
last_update_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MicrodaoDashboard {
|
||||||
|
microdao: MicrodaoSummary;
|
||||||
|
stats: MicrodaoStats;
|
||||||
|
recent_activity: MicrodaoActivity[];
|
||||||
|
rooms: CityRoomSummary[];
|
||||||
|
citizens: PublicCitizenSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export PublicCitizenSummary from citizens types
|
||||||
|
import type { PublicCitizenSummary } from './citizens';
|
||||||
|
export type { PublicCitizenSummary };
|
||||||
|
|
||||||
|
|||||||
20
docs/sql/seed_microdao_activity_daarion.sql
Normal file
20
docs/sql/seed_microdao_activity_daarion.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Seed data for DAARION MicroDAO activity feed
|
||||||
|
-- Run this after migrations 044 and 045 are applied
|
||||||
|
|
||||||
|
INSERT INTO microdao_activity (microdao_slug, kind, title, body, author_name)
|
||||||
|
VALUES
|
||||||
|
('daarion', 'post', 'Launch of DAARION City Lobby', 'Відкрили головний публічний чат DAARION City з агентом DAARWIZZ. Тепер всі громадяни можуть спілкуватися в реальному часі.', 'DAARWIZZ'),
|
||||||
|
('daarion', 'update', 'NODE2 DAGI Stack Online', 'На НОДА2 розгорнуто DAGI Router та 8 моделей Swapper. Система працює стабільно, всі агенти доступні.', 'Helix'),
|
||||||
|
('daarion', 'post', 'Energy Union MicroDAO created', 'Запустили платформу Energy Union для оптимізації енергетичних систем. Новий MicroDAO готовий до роботи.', 'Solarius'),
|
||||||
|
('daarion', 'event', 'City Infrastructure Update', 'Оновлено інфраструктуру міста: додано підтримку мультимодальних агентів, покращено маршрутизацію повідомлень.', 'System'),
|
||||||
|
('daarion', 'post', 'New Citizens Welcome', 'Вітаємо нових громадян DAARION! Система активно розвивається, дякуємо за участь.', 'DAARWIZZ'),
|
||||||
|
('daarion', 'update', 'Matrix Gateway Integration', 'Повністю інтегровано Matrix Gateway для децентралізованого обміну повідомленнями між кімнатами.', 'Helix'),
|
||||||
|
('daarion', 'post', 'MicroDAO Dashboard Released', 'Запущено новий дашборд для MicroDAO з метриками, активністю та управлінням командою.', 'System'),
|
||||||
|
('daarion', 'event', 'Node Registry Self-Healing', 'Активовано систему самовідновлення для Node Registry. Всі ноди моніторяться автоматично.', 'System'),
|
||||||
|
('daarion', 'update', 'Swapper Models Updated', 'Оновлено моделі в Swapper: додано підтримку нових мовних моделей та оптимізовано використання VRAM.', 'Helix'),
|
||||||
|
('daarion', 'post', 'Community Growth', 'DAARION City зростає! Кількість активних агентів та громадян збільшилася вдвічі за останній місяць.', 'DAARWIZZ');
|
||||||
|
|
||||||
|
-- Optional: Link some key agents to DAARION if they exist
|
||||||
|
-- UPDATE agents SET home_microdao_id = (SELECT id FROM city_microdao WHERE slug = 'daarion')
|
||||||
|
-- WHERE slug IN ('daarwizz', 'helix', 'solarius', 'yaromir', 'monitor');
|
||||||
|
|
||||||
22
migrations/044_microdao_activity.sql
Normal file
22
migrations/044_microdao_activity.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- 044_microdao_activity.sql
|
||||||
|
-- Migration: Create microdao_activity table for news/updates/events feed
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS microdao_activity (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
microdao_slug TEXT NOT NULL REFERENCES microdaos(slug) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('post', 'event', 'update')),
|
||||||
|
title TEXT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author_agent_id TEXT NULL,
|
||||||
|
author_name TEXT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_microdao_activity_microdao_created_at
|
||||||
|
ON microdao_activity (microdao_slug, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE microdao_activity IS 'Activity feed for MicroDAO: posts, events, updates';
|
||||||
|
COMMENT ON COLUMN microdao_activity.kind IS 'Type: post (news), event (announcement), update (status change)';
|
||||||
|
COMMENT ON COLUMN microdao_activity.author_agent_id IS 'Optional reference to agent who created this activity';
|
||||||
|
COMMENT ON COLUMN microdao_activity.author_name IS 'Fallback author name if agent_id is not set';
|
||||||
|
|
||||||
14
migrations/045_microdao_stats.sql
Normal file
14
migrations/045_microdao_stats.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- 045_microdao_stats.sql
|
||||||
|
-- Migration: Add statistics columns to microdaos table
|
||||||
|
|
||||||
|
ALTER TABLE microdaos
|
||||||
|
ADD COLUMN IF NOT EXISTS citizens_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS rooms_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS agents_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_update_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN microdaos.citizens_count IS 'Cached count of citizens (public agents) linked to this MicroDAO';
|
||||||
|
COMMENT ON COLUMN microdaos.rooms_count IS 'Cached count of rooms belonging to this MicroDAO';
|
||||||
|
COMMENT ON COLUMN microdaos.agents_count IS 'Cached count of agents (all) linked to this MicroDAO';
|
||||||
|
COMMENT ON COLUMN microdaos.last_update_at IS 'Last time statistics were updated';
|
||||||
|
|
||||||
@@ -4248,8 +4248,13 @@ async def get_microdao_dashboard(slug: str) -> dict:
|
|||||||
# Конвертувати citizens в PublicCitizenSummary
|
# Конвертувати citizens в PublicCitizenSummary
|
||||||
citizen_summaries = []
|
citizen_summaries = []
|
||||||
for citizen in citizens:
|
for citizen in citizens:
|
||||||
|
# Переконатися що slug не None
|
||||||
|
slug = citizen.get("slug")
|
||||||
|
if not slug:
|
||||||
|
continue # Пропустити громадян без slug
|
||||||
|
|
||||||
citizen_summaries.append({
|
citizen_summaries.append({
|
||||||
"slug": citizen.get("slug"),
|
"slug": slug,
|
||||||
"display_name": citizen["display_name"],
|
"display_name": citizen["display_name"],
|
||||||
"public_title": citizen.get("public_title"),
|
"public_title": citizen.get("public_title"),
|
||||||
"public_tagline": citizen.get("public_tagline"),
|
"public_tagline": citizen.get("public_tagline"),
|
||||||
@@ -4257,7 +4262,7 @@ async def get_microdao_dashboard(slug: str) -> dict:
|
|||||||
"kind": citizen.get("kind"),
|
"kind": citizen.get("kind"),
|
||||||
"district": citizen.get("district"),
|
"district": citizen.get("district"),
|
||||||
"primary_room_slug": citizen.get("primary_room_slug"),
|
"primary_room_slug": citizen.get("primary_room_slug"),
|
||||||
"public_skills": citizen.get("public_skills", []),
|
"public_skills": list(citizen.get("public_skills", [])) if citizen.get("public_skills") else [],
|
||||||
"online_status": citizen.get("status", "unknown"),
|
"online_status": citizen.get("status", "unknown"),
|
||||||
"status": citizen.get("status"),
|
"status": citizen.get("status"),
|
||||||
"node_id": citizen.get("node_id"),
|
"node_id": citizen.get("node_id"),
|
||||||
@@ -4268,15 +4273,20 @@ async def get_microdao_dashboard(slug: str) -> dict:
|
|||||||
# Конвертувати activity в MicrodaoActivity
|
# Конвертувати activity в MicrodaoActivity
|
||||||
activity_list = []
|
activity_list = []
|
||||||
for act in activity:
|
for act in activity:
|
||||||
|
# Конвертувати UUID в string, якщо потрібно
|
||||||
|
author_id = act.get("author_agent_id")
|
||||||
|
if author_id:
|
||||||
|
author_id = str(author_id) if not isinstance(author_id, str) else author_id
|
||||||
|
|
||||||
activity_list.append({
|
activity_list.append({
|
||||||
"id": str(act["id"]),
|
"id": str(act["id"]),
|
||||||
"microdao_slug": act["microdao_slug"],
|
"microdao_slug": act["microdao_slug"],
|
||||||
"kind": act["kind"],
|
"kind": act["kind"],
|
||||||
"title": act.get("title"),
|
"title": act.get("title"),
|
||||||
"body": act["body"],
|
"body": act["body"],
|
||||||
"author_agent_id": str(act["author_agent_id"]) if act.get("author_agent_id") else None,
|
"author_agent_id": author_id,
|
||||||
"author_name": act.get("author_name"),
|
"author_name": act.get("author_name"),
|
||||||
"created_at": act["created_at"]
|
"created_at": act["created_at"].isoformat() if hasattr(act["created_at"], "isoformat") else str(act["created_at"])
|
||||||
})
|
})
|
||||||
|
|
||||||
# Створити MicrodaoSummary
|
# Створити MicrodaoSummary
|
||||||
@@ -4305,11 +4315,17 @@ async def get_microdao_dashboard(slug: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Створити stats
|
# Створити stats
|
||||||
|
last_update = microdao.get("updated_at")
|
||||||
|
if last_update and hasattr(last_update, "isoformat"):
|
||||||
|
last_update = last_update.isoformat()
|
||||||
|
elif last_update:
|
||||||
|
last_update = str(last_update)
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
"rooms_count": rooms_count,
|
"rooms_count": rooms_count,
|
||||||
"citizens_count": citizens_count,
|
"citizens_count": citizens_count,
|
||||||
"agents_count": agents_count,
|
"agents_count": agents_count,
|
||||||
"last_update_at": microdao.get("updated_at")
|
"last_update_at": last_update
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4792,12 +4792,47 @@ async def trigger_node_self_healing(node_id: str):
|
|||||||
async def api_get_microdao_dashboard(slug: str):
|
async def api_get_microdao_dashboard(slug: str):
|
||||||
"""Отримати повний дашборд для MicroDAO"""
|
"""Отримати повний дашборд для MicroDAO"""
|
||||||
try:
|
try:
|
||||||
dashboard = await repo_city.get_microdao_dashboard(slug)
|
dashboard_dict = await repo_city.get_microdao_dashboard(slug)
|
||||||
|
|
||||||
|
# Конвертувати dict в Pydantic моделі
|
||||||
|
# MicrodaoSummary вже правильно сформований
|
||||||
|
# Stats потрібно конвертувати
|
||||||
|
stats = dashboard_dict["stats"]
|
||||||
|
if stats.get("last_update_at"):
|
||||||
|
try:
|
||||||
|
if isinstance(stats["last_update_at"], str):
|
||||||
|
stats["last_update_at"] = datetime.fromisoformat(stats["last_update_at"].replace("Z", "+00:00"))
|
||||||
|
elif hasattr(stats["last_update_at"], "isoformat"):
|
||||||
|
pass # Вже datetime
|
||||||
|
except Exception:
|
||||||
|
stats["last_update_at"] = None
|
||||||
|
|
||||||
|
# Activity потрібно конвертувати
|
||||||
|
activity_list = []
|
||||||
|
for act in dashboard_dict["recent_activity"]:
|
||||||
|
act_dict = dict(act)
|
||||||
|
if act_dict.get("created_at"):
|
||||||
|
try:
|
||||||
|
if isinstance(act_dict["created_at"], str):
|
||||||
|
act_dict["created_at"] = datetime.fromisoformat(act_dict["created_at"].replace("Z", "+00:00"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
activity_list.append(MicrodaoActivity(**act_dict))
|
||||||
|
|
||||||
|
# Створити повний об'єкт
|
||||||
|
dashboard = MicrodaoDashboard(
|
||||||
|
microdao=MicrodaoSummary(**dashboard_dict["microdao"]),
|
||||||
|
stats=MicrodaoStats(**stats),
|
||||||
|
recent_activity=activity_list,
|
||||||
|
rooms=[CityRoomSummary(**r) for r in dashboard_dict["rooms"]],
|
||||||
|
citizens=[PublicCitizenSummary(**c) for c in dashboard_dict["citizens"]]
|
||||||
|
)
|
||||||
|
|
||||||
return dashboard
|
return dashboard
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get microdao dashboard for {slug}: {e}")
|
logger.error(f"Failed to get microdao dashboard for {slug}: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Failed to load dashboard")
|
raise HTTPException(status_code=500, detail="Failed to load dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user