feat: add MicroDAO branding and Agent avatar upload UI
This commit is contained in:
@@ -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}
|
||||
|
||||
50
apps/web/src/app/api/agents/[agentId]/dais/route.ts
Normal file
50
apps/web/src/app/api/agents/[agentId]/dais/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
37
apps/web/src/app/api/assets/upload/route.ts
Normal file
37
apps/web/src/app/api/assets/upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
50
apps/web/src/app/api/microdao/[slug]/branding/route.ts
Normal file
50
apps/web/src/app/api/microdao/[slug]/branding/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
179
apps/web/src/components/agent-dashboard/AgentAvatarUpload.tsx
Normal file
179
apps/web/src/components/agent-dashboard/AgentAvatarUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ export { AgentPublicProfileCard } from './AgentPublicProfileCard';
|
||||
export { AgentMicrodaoMembershipCard } from './AgentMicrodaoMembershipCard';
|
||||
export { AgentVisibilityCard } from './AgentVisibilityCard';
|
||||
export { CreateMicrodaoCard } from './CreateMicrodaoCard';
|
||||
export { AgentAvatarUpload } from './AgentAvatarUpload';
|
||||
|
||||
242
apps/web/src/components/microdao/MicrodaoBrandingCard.tsx
Normal file
242
apps/web/src/components/microdao/MicrodaoBrandingCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface MicrodaoDetail {
|
||||
|
||||
// Content
|
||||
logo_url?: string | null;
|
||||
banner_url?: string | null;
|
||||
agents: MicrodaoAgentView[];
|
||||
channels: MicrodaoChannelView[];
|
||||
public_citizens: MicrodaoCitizenView[];
|
||||
|
||||
176
docs/tasks/TASK_PHASE_LOGOS_BACKGROUNDS_UI_v1.md
Normal file
176
docs/tasks/TASK_PHASE_LOGOS_BACKGROUNDS_UI_v1.md
Normal 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]`
|
||||
|
||||
Reference in New Issue
Block a user