feat: add MicroDAO branding and Agent avatar upload UI

This commit is contained in:
Apple
2025-12-01 02:26:02 -08:00
parent 95b75d5897
commit d4e20ea513
10 changed files with 760 additions and 3 deletions

View File

@@ -13,7 +13,8 @@ import {
AgentPublicProfileCard,
AgentMicrodaoMembershipCard,
AgentVisibilityCard,
CreateMicrodaoCard
CreateMicrodaoCard,
AgentAvatarUpload
} from '@/components/agent-dashboard';
import { api, Agent, AgentInvokeResponse } from '@/lib/api';
import { VisibilityScope, getNodeBadgeLabel } from '@/lib/types/agents';
@@ -442,6 +443,15 @@ export default function AgentConsolePage() {
</p>
</div>
{/* Avatar Upload */}
<AgentAvatarUpload
agentId={dashboard.profile.agent_id}
currentAvatarUrl={profile?.avatar_url || dashboard.profile.dais?.vis?.avatar_url}
displayName={profile?.display_name || dashboard.profile.display_name}
canEdit={true}
onUpdated={refresh}
/>
{/* Visibility Settings */}
<AgentVisibilityCard
agentId={dashboard.profile.agent_id}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_SERVICE_URL =
process.env.INTERNAL_API_URL ||
process.env.CITY_SERVICE_URL ||
'http://daarion-city-service:7001';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ agentId: string }> }
) {
const { agentId } = await params;
if (!agentId) {
return NextResponse.json({ error: 'agentId is required' }, { status: 400 });
}
try {
const body = await request.json();
const upstream = await fetch(
`${CITY_SERVICE_URL}/city/agents/${encodeURIComponent(agentId)}/dais`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!upstream.ok) {
const errorText = await upstream.text();
return NextResponse.json(
{ error: 'Failed to update DAIS', details: errorText },
{ status: upstream.status }
);
}
const data = await upstream.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
return NextResponse.json(
{
error: 'Failed to update DAIS',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_SERVICE_URL =
process.env.INTERNAL_API_URL ||
process.env.CITY_SERVICE_URL ||
'http://daarion-city-service:7001';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const upstream = await fetch(`${CITY_SERVICE_URL}/city/assets/upload`, {
method: 'POST',
body: formData,
});
if (!upstream.ok) {
const errorText = await upstream.text();
return NextResponse.json(
{ error: 'Upload failed', details: errorText },
{ status: upstream.status }
);
}
const data = await upstream.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
return NextResponse.json(
{
error: 'Upload failed',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
const CITY_SERVICE_URL =
process.env.INTERNAL_API_URL ||
process.env.CITY_SERVICE_URL ||
'http://daarion-city-service:7001';
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
if (!slug) {
return NextResponse.json({ error: 'slug is required' }, { status: 400 });
}
try {
const body = await request.json();
const upstream = await fetch(
`${CITY_SERVICE_URL}/city/microdao/${encodeURIComponent(slug)}/branding`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!upstream.ok) {
const errorText = await upstream.text();
return NextResponse.json(
{ error: 'Failed to update branding', details: errorText },
{ status: upstream.status }
);
}
const data = await upstream.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
return NextResponse.json(
{
error: 'Failed to update branding',
details: error instanceof Error ? error.message : String(error),
},
{ status: 500 }
);
}
}

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { useMicrodaoDetail, useMicrodaoRooms, useMicrodaoAgents } from "@/hooks/useMicrodao";
import { DISTRICT_COLORS } from "@/lib/microdao";
import { MicrodaoVisibilityCard } from "@/components/microdao/MicrodaoVisibilityCard";
import { MicrodaoBrandingCard } from "@/components/microdao/MicrodaoBrandingCard";
import { MicrodaoRoomsSection } from "@/components/microdao/MicrodaoRoomsSection";
import { MicrodaoRoomsAdminPanel } from "@/components/microdao/MicrodaoRoomsAdminPanel";
import { MicrodaoAgentsSection } from "@/components/microdao/MicrodaoAgentsSection";
@@ -391,9 +392,19 @@ export default function MicrodaoDetailPage() {
</section>
</div>
{/* Visibility Settings (only for orchestrator) */}
{/* Settings (only for orchestrator) */}
{orchestrator && canManage && (
<div className="pt-8 border-t border-white/5">
<div className="pt-8 border-t border-white/5 space-y-6">
{/* Branding */}
<MicrodaoBrandingCard
slug={slug}
logoUrl={microdao.logo_url}
bannerUrl={microdao.banner_url}
canEdit={canManage}
onUpdated={() => refreshMicrodao()}
/>
{/* Visibility */}
<MicrodaoVisibilityCard
microdaoId={microdao.id}
isPublic={microdao.is_public}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState, useRef } from 'react';
import { Upload, User, X, Loader2 } from 'lucide-react';
interface AgentAvatarUploadProps {
agentId: string;
currentAvatarUrl?: string | null;
displayName?: string;
canEdit?: boolean;
onUpdated?: () => void;
}
export function AgentAvatarUpload({
agentId,
currentAvatarUrl,
displayName = 'Agent',
canEdit = false,
onUpdated,
}: AgentAvatarUploadProps) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(currentAvatarUrl || null);
const inputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (file: File) => {
setUploading(true);
setError(null);
try {
// Upload file
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'agent_avatar');
const uploadRes = await fetch('/api/assets/upload', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload file');
}
const uploadData = await uploadRes.json();
const imageUrl = uploadData.processed_url || uploadData.original_url;
// Update agent DAIS
const updateRes = await fetch(`/api/agents/${agentId}/dais`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vis: { avatar_url: imageUrl },
}),
});
if (!updateRes.ok) {
throw new Error('Failed to update avatar');
}
setPreviewUrl(imageUrl);
onUpdated?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploading(false);
}
};
const handleRemove = async () => {
setUploading(true);
setError(null);
try {
const updateRes = await fetch(`/api/agents/${agentId}/dais`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vis: { avatar_url: null },
}),
});
if (!updateRes.ok) {
throw new Error('Failed to remove avatar');
}
setPreviewUrl(null);
onUpdated?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Remove failed');
} finally {
setUploading(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleUpload(file);
}
};
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<User className="w-5 h-5 text-violet-400" />
Avatar
</h3>
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<div className="flex items-start gap-4">
{/* Avatar Preview */}
<div className="relative">
<div className="w-24 h-24 rounded-xl bg-gradient-to-br from-violet-500/30 to-purple-600/30 border border-white/10 overflow-hidden group">
{previewUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={displayName}
className="w-full h-full object-cover"
/>
{canEdit && (
<button
onClick={handleRemove}
disabled={uploading}
className="absolute top-1 right-1 p-1 bg-red-500/80 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3 text-white" />
</button>
)}
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<User className="w-10 h-10 text-violet-400/50" />
</div>
)}
{uploading && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Loader2 className="w-6 h-6 text-white animate-spin" />
</div>
)}
</div>
</div>
{/* Upload Controls */}
{canEdit && (
<div className="flex-1">
<p className="text-sm text-white/50 mb-3">
Upload a custom avatar for this agent. Recommended size: 256x256px.
</p>
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-4 py-2 text-sm bg-violet-500/20 hover:bg-violet-500/30 border border-violet-500/30 rounded-lg text-violet-300 transition-colors disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{previewUrl ? 'Change Avatar' : 'Upload Avatar'}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -7,3 +7,4 @@ export { AgentPublicProfileCard } from './AgentPublicProfileCard';
export { AgentMicrodaoMembershipCard } from './AgentMicrodaoMembershipCard';
export { AgentVisibilityCard } from './AgentVisibilityCard';
export { CreateMicrodaoCard } from './CreateMicrodaoCard';
export { AgentAvatarUpload } from './AgentAvatarUpload';

View File

@@ -0,0 +1,242 @@
'use client';
import { useState, useRef } from 'react';
import { Upload, Image, X, Building2, Loader2 } from 'lucide-react';
interface MicrodaoBrandingCardProps {
slug: string;
logoUrl?: string | null;
bannerUrl?: string | null;
canEdit?: boolean;
onUpdated?: () => void;
}
export function MicrodaoBrandingCard({
slug,
logoUrl,
bannerUrl,
canEdit = false,
onUpdated,
}: MicrodaoBrandingCardProps) {
const [uploading, setUploading] = useState<'logo' | 'banner' | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewLogo, setPreviewLogo] = useState<string | null>(logoUrl || null);
const [previewBanner, setPreviewBanner] = useState<string | null>(bannerUrl || null);
const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (file: File, type: 'microdao_logo' | 'microdao_banner') => {
const isLogo = type === 'microdao_logo';
setUploading(isLogo ? 'logo' : 'banner');
setError(null);
try {
// Upload file
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
const uploadRes = await fetch('/api/assets/upload', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Failed to upload file');
}
const uploadData = await uploadRes.json();
const imageUrl = uploadData.processed_url || uploadData.original_url;
// Update branding
const brandingRes = await fetch(`/api/microdao/${slug}/branding`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
isLogo ? { logo_url: imageUrl } : { banner_url: imageUrl }
),
});
if (!brandingRes.ok) {
throw new Error('Failed to update branding');
}
// Update preview
if (isLogo) {
setPreviewLogo(imageUrl);
} else {
setPreviewBanner(imageUrl);
}
onUpdated?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploading(null);
}
};
const handleRemove = async (type: 'logo' | 'banner') => {
setUploading(type);
setError(null);
try {
const brandingRes = await fetch(`/api/microdao/${slug}/branding`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
type === 'logo' ? { logo_url: null } : { banner_url: null }
),
});
if (!brandingRes.ok) {
throw new Error('Failed to remove image');
}
if (type === 'logo') {
setPreviewLogo(null);
} else {
setPreviewBanner(null);
}
onUpdated?.();
} catch (e) {
setError(e instanceof Error ? e.message : 'Remove failed');
} finally {
setUploading(null);
}
};
const handleFileChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: 'microdao_logo' | 'microdao_banner'
) => {
const file = e.target.files?.[0];
if (file) {
handleUpload(file, type);
}
};
if (!canEdit) {
return null;
}
return (
<div className="bg-white/5 backdrop-blur-md rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Image className="w-5 h-5 text-cyan-400" />
Branding
</h3>
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Logo */}
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Logo
</label>
<div className="relative w-24 h-24 rounded-xl bg-slate-800 border border-white/10 overflow-hidden group">
{previewLogo ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewLogo}
alt="Logo"
className="w-full h-full object-cover"
/>
<button
onClick={() => handleRemove('logo')}
disabled={uploading === 'logo'}
className="absolute top-1 right-1 p-1 bg-red-500/80 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3 text-white" />
</button>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<Building2 className="w-8 h-8 text-slate-600" />
</div>
)}
{uploading === 'logo' && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Loader2 className="w-6 h-6 text-white animate-spin" />
</div>
)}
</div>
<input
ref={logoInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileChange(e, 'microdao_logo')}
className="hidden"
/>
<button
onClick={() => logoInputRef.current?.click()}
disabled={!!uploading}
className="mt-2 flex items-center gap-2 px-3 py-1.5 text-sm bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-white/70 transition-colors disabled:opacity-50"
>
<Upload className="w-4 h-4" />
Upload Logo
</button>
</div>
{/* Banner */}
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Banner
</label>
<div className="relative w-full h-24 rounded-xl bg-slate-800 border border-white/10 overflow-hidden group">
{previewBanner ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewBanner}
alt="Banner"
className="w-full h-full object-cover"
/>
<button
onClick={() => handleRemove('banner')}
disabled={uploading === 'banner'}
className="absolute top-1 right-1 p-1 bg-red-500/80 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3 text-white" />
</button>
</>
) : (
<div className="w-full h-full flex items-center justify-center">
<Image className="w-8 h-8 text-slate-600" />
</div>
)}
{uploading === 'banner' && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Loader2 className="w-6 h-6 text-white animate-spin" />
</div>
)}
</div>
<input
ref={bannerInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileChange(e, 'microdao_banner')}
className="hidden"
/>
<button
onClick={() => bannerInputRef.current?.click()}
disabled={!!uploading}
className="mt-2 flex items-center gap-2 px-3 py-1.5 text-sm bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-white/70 transition-colors disabled:opacity-50"
>
<Upload className="w-4 h-4" />
Upload Banner
</button>
</div>
</div>
</div>
);
}

View File

@@ -143,6 +143,7 @@ export interface MicrodaoDetail {
// Content
logo_url?: string | null;
banner_url?: string | null;
agents: MicrodaoAgentView[];
channels: MicrodaoChannelView[];
public_citizens: MicrodaoCitizenView[];

View File

@@ -0,0 +1,176 @@
# TASK_PHASE_LOGOS_BACKGROUNDS_UI_v1
Проєкт: DAARION.city — Логотипи та фони MicroDAO / Agents
Фаза: UI для завантаження та відображення брендингу
Мета: Додати можливість завантажувати та редагувати логотипи MicroDAO та аватарки агентів у новому фронтенді (`apps/web`).
---
## 0. Поточний стан
- Поля `logo_url`, `banner_url` існують у БД для MicroDAO.
- Поле `avatar_url` існує для агентів (в `dais.vis.avatar_url` або напряму).
- Старий фронтенд (`src/features/microdao/MicrodaoConsolePage.tsx`) має UI для завантаження.
- Новий фронтенд (`apps/web`) **не має** UI для завантаження — тільки відображення.
- API endpoint `PATCH /microdao/{slug}/branding` існує.
- API endpoint для завантаження файлів (`POST /assets/upload`) існує.
---
## 1. Scope
### Включено
1. Компонент `MicrodaoBrandingCard` для редагування логотипа та банера MicroDAO.
2. Компонент `AgentAvatarUpload` для редагування аватарки агента.
3. Інтеграція в сторінки `/microdao/[slug]` та `/agents/[agentId]`.
4. API routes для проксі завантаження файлів.
5. Відображення placeholder при відсутності зображень.
### Виключено
- Складні редактори зображень (crop, resize).
- Генерація AI-аватарок (окремий таск).
---
## 2. Backend API (існуючі endpoints)
### MicroDAO Branding
```
PATCH /city/microdao/{slug}/branding
Body: { "logo_url": "...", "banner_url": "..." }
```
### Asset Upload
```
POST /city/assets/upload
Form: file, type (microdao_logo, microdao_banner, agent_avatar)
Response: { original_url, processed_url, thumb_url }
```
### Agent Avatar Update
```
PATCH /city/agents/{agent_id}/dais
Body: { "vis": { "avatar_url": "..." } }
```
---
## 3. Frontend Components
### 3.1. MicrodaoBrandingCard
Файл: `apps/web/src/components/microdao/MicrodaoBrandingCard.tsx`
```tsx
'use client';
import { useState, useRef } from 'react';
import { Upload, Image, X } from 'lucide-react';
interface MicrodaoBrandingCardProps {
slug: string;
logoUrl?: string | null;
bannerUrl?: string | null;
canEdit?: boolean;
onUpdated?: () => void;
}
export function MicrodaoBrandingCard({
slug,
logoUrl,
bannerUrl,
canEdit = false,
onUpdated
}: MicrodaoBrandingCardProps) {
// ... implementation
}
```
### 3.2. AgentAvatarUpload
Файл: `apps/web/src/components/agent-dashboard/AgentAvatarUpload.tsx`
```tsx
'use client';
interface AgentAvatarUploadProps {
agentId: string;
currentAvatarUrl?: string | null;
canEdit?: boolean;
onUpdated?: () => void;
}
export function AgentAvatarUpload({
agentId,
currentAvatarUrl,
canEdit = false,
onUpdated
}: AgentAvatarUploadProps) {
// ... implementation
}
```
### 3.3. API Route for Upload
Файл: `apps/web/src/app/api/assets/upload/route.ts`
```ts
import { NextRequest, NextResponse } from 'next/server';
const CITY_SERVICE_URL = process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001';
export async function POST(request: NextRequest) {
const formData = await request.formData();
const upstream = await fetch(`${CITY_SERVICE_URL}/city/assets/upload`, {
method: 'POST',
body: formData,
});
const data = await upstream.json();
return NextResponse.json(data, { status: upstream.status });
}
```
---
## 4. Integration Points
### 4.1. MicroDAO Detail Page
В `apps/web/src/app/microdao/[slug]/page.tsx`:
- Додати `MicrodaoBrandingCard` у секцію Hero або окрему вкладку Settings.
- Показувати тільки якщо `canManage === true`.
### 4.2. Agent Console Page
В `apps/web/src/app/agents/[agentId]/page.tsx`:
- Додати `AgentAvatarUpload` у вкладку Identity.
- Показувати тільки якщо агент належить поточному користувачу.
---
## 5. Acceptance Criteria
1. На сторінці MicroDAO (для orchestrator) є можливість:
- Завантажити/змінити логотип
- Завантажити/змінити банер
- Видалити зображення
2. На сторінці агента (для власника) є можливість:
- Завантажити/змінити аватарку
- Видалити аватарку
3. Зображення відображаються одразу після завантаження.
4. При відсутності зображення показується placeholder.
5. Помилки завантаження показуються користувачу.
---
## 6. Deliverables
- `apps/web/src/components/microdao/MicrodaoBrandingCard.tsx`
- `apps/web/src/components/agent-dashboard/AgentAvatarUpload.tsx`
- `apps/web/src/app/api/assets/upload/route.ts`
- Оновлені сторінки `/microdao/[slug]` та `/agents/[agentId]`