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:
Apple
2025-12-02 06:37:16 -08:00
parent 95c9a17a7a
commit ace183e136
15 changed files with 686 additions and 9 deletions

View 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 }
);
}
}

View 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 }
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,7 +1,8 @@
'use client';
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 {
district?: string;
@@ -302,3 +303,60 @@ export function useMicrodaoAgents(
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 };
}

View File

@@ -2,7 +2,13 @@
* MicroDAO API Client (Task 029)
*/
import { MicrodaoOption, CityRoomSummary } from "@/lib/types/microdao";
import {
MicrodaoOption,
CityRoomSummary,
MicrodaoDashboard,
MicrodaoActivity,
CreateMicrodaoActivity
} from "@/lib/types/microdao";
// =============================================================================
// Types

View File

@@ -177,3 +177,47 @@ export interface AgentMicrodaoMembership {
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 };