- {profile.dais.vis?.avatar_url ? (
+ {normalizeAssetUrl(profile.dais.vis?.avatar_url) ? (

diff --git a/apps/web/src/components/chat/AgentChatWidget.tsx b/apps/web/src/components/chat/AgentChatWidget.tsx
index c1e75c41..1e25dd10 100644
--- a/apps/web/src/components/chat/AgentChatWidget.tsx
+++ b/apps/web/src/components/chat/AgentChatWidget.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { MessageCircle, X, Bot, Server, Building2, Loader2, AlertCircle } from 'lucide-react';
import { CityChatWidget } from '@/components/city/CityChatWidget';
import { cn } from '@/lib/utils';
+import { normalizeAssetUrl } from '@/lib/utils/assetUrl';
export type ChatContextType = 'agent' | 'node' | 'microdao';
@@ -111,7 +112,7 @@ export function AgentChatWidget({ contextType, contextId, className }: AgentChat
name: chatInfo.agent_display_name || 'Agent Chat',
icon: Bot,
status: chatInfo.agent_status || 'offline',
- avatarUrl: chatInfo.agent_avatar_url
+ avatarUrl: normalizeAssetUrl(chatInfo.agent_avatar_url)
};
case 'node':
return {
@@ -125,7 +126,7 @@ export function AgentChatWidget({ contextType, contextId, className }: AgentChat
name: chatInfo.microdao_name || 'MicroDAO Chat',
icon: Building2,
status: chatInfo.orchestrator?.status || 'offline',
- avatarUrl: chatInfo.orchestrator?.avatar_url
+ avatarUrl: normalizeAssetUrl(chatInfo.orchestrator?.avatar_url)
};
default:
return { name: 'Chat', icon: MessageCircle, status: 'offline' };
diff --git a/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx b/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx
index bf7456b7..9f285c94 100644
--- a/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx
+++ b/apps/web/src/components/microdao/MicrodaoAgentsSection.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Bot, Crown, Users, Shield, Sparkles } from "lucide-react";
import { MicrodaoAgent } from "@/hooks/useMicrodao";
+import { normalizeAssetUrl } from "@/lib/utils/assetUrl";
interface MicrodaoAgentsSectionProps {
agents: MicrodaoAgent[];
@@ -140,8 +141,8 @@ function AgentCard({ agent, featured = false, compact = false }: AgentCardProps)
- {agent.avatar_url ? (
-

+ {normalizeAssetUrl(agent.avatar_url) ? (
+
!})
) : (
)}
@@ -169,8 +170,8 @@ function AgentCard({ agent, featured = false, compact = false }: AgentCardProps)
- {agent.avatar_url ? (
-

+ {normalizeAssetUrl(agent.avatar_url) ? (
+
!})
) : (
)}
diff --git a/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx b/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx
index bb3ed6c8..0590bbb1 100644
--- a/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx
+++ b/apps/web/src/components/microdao/MicrodaoBrandingCard.tsx
@@ -2,6 +2,7 @@
import { useState, useRef } from 'react';
import { Upload, Image, X, Building2, Loader2 } from 'lucide-react';
+import { normalizeAssetUrl } from '@/lib/utils/assetUrl';
interface MicrodaoBrandingCardProps {
slug: string;
@@ -20,8 +21,8 @@ export function MicrodaoBrandingCard({
}: MicrodaoBrandingCardProps) {
const [uploading, setUploading] = useState<'logo' | 'banner' | null>(null);
const [error, setError] = useState
(null);
- const [previewLogo, setPreviewLogo] = useState(logoUrl || null);
- const [previewBanner, setPreviewBanner] = useState(bannerUrl || null);
+ const [previewLogo, setPreviewLogo] = useState(normalizeAssetUrl(logoUrl));
+ const [previewBanner, setPreviewBanner] = useState(normalizeAssetUrl(bannerUrl));
const logoInputRef = useRef(null);
const bannerInputRef = useRef(null);
diff --git a/apps/web/src/lib/utils/assetUrl.ts b/apps/web/src/lib/utils/assetUrl.ts
index 3245647f..6a4e9501 100644
--- a/apps/web/src/lib/utils/assetUrl.ts
+++ b/apps/web/src/lib/utils/assetUrl.ts
@@ -1,35 +1,59 @@
/**
- * Normalize asset URL for display
+ * Normalize asset URL for display.
+ * This is the SINGLE source of truth for building static asset URLs.
+ *
* Handles various URL formats from the backend:
- * - /api/static/uploads/... - already correct
- * - /assets/... - static assets, use as-is
+ * - /api/static/uploads/... - already correct, return as-is
+ * - /assets/... - static assets in public folder, return as-is
* - /static/uploads/... - needs /api prefix
- * - https://... - external URL, use as-is
+ * - https://... or http://... - external URL, return as-is
+ * - uploads/... or static/uploads/... - relative path, needs /api/static prefix
+ *
+ * IMPORTANT: Do NOT manually concatenate /api/static anywhere in the codebase.
+ * Always use this function instead.
*/
export function normalizeAssetUrl(url: string | null | undefined): string | null {
if (!url) return null;
- // Already correct format
- if (url.startsWith('/api/static')) {
- return url;
- }
-
- // Static assets in public folder
- if (url.startsWith('/assets/')) {
- return url;
- }
-
- // External URLs
+ // External URLs - return as-is
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
- // Old format - needs /api prefix
+ // Already correct format with /api/static
+ if (url.startsWith('/api/static')) {
+ return url;
+ }
+
+ // Static assets in public folder (/assets/...)
+ if (url.startsWith('/assets/')) {
+ return url;
+ }
+
+ // Old format with /static/ prefix - add /api
if (url.startsWith('/static/')) {
return `/api${url}`;
}
- // Unknown format - return as-is
+ // Relative path without leading slash (uploads/..., static/...)
+ // Remove any duplicate prefixes and normalize
+ let cleaned = url;
+
+ // Remove leading /api/static if somehow duplicated
+ cleaned = cleaned.replace(/^\/api\/static\//, '');
+ // Remove leading /static/
+ cleaned = cleaned.replace(/^\/static\//, '');
+ // Remove leading static/ (no slash)
+ cleaned = cleaned.replace(/^static\//, '');
+ // Remove leading slash
+ cleaned = cleaned.replace(/^\/+/, '');
+
+ // If it looks like a relative upload path, prefix with /api/static/
+ if (cleaned.startsWith('uploads/') || cleaned.includes('/')) {
+ return `/api/static/${cleaned}`;
+ }
+
+ // Unknown format - return as-is (might be a simple filename)
return url;
}