aethex-forge/client/pages/ethos/ArtistProfile.tsx
AeThex 7fec93e05c
Some checks are pending
Build / build (push) Waiting to run
Deploy / deploy (push) Waiting to run
Lint & Type Check / lint (push) Waiting to run
Security Scan / dependency-check (push) Waiting to run
Security Scan / semgrep (push) Waiting to run
Test / test (18.x) (push) Waiting to run
Test / test (20.x) (push) Waiting to run
feat: Authentik SSO, nav systems, project pages, and schema fixes
Auth & SSO
- Wire Authentik (auth.aethex.tech) as OIDC PKCE SSO provider
- Server-side only flow with HMAC-signed stateless state token
- Account linking via authentik_sub in user metadata
- AeThex ID connection card in Dashboard connections tab
- Unlink endpoint POST /api/auth/authentik/unlink
- Fix node:https helper to bypass undici DNS bug on Node 18
- Fix resolv.conf to use 1.1.1.1/8.8.8.8 in container

Schema & types
- Regenerate database.types.ts from live Supabase schema (23k lines)
- Fix 511 TypeScript errors caused by stale 582-line types file
- Fix UserProfile import in aethex-database-adapter.ts
- Add notifications migration (title, message, read columns)

Server fixes
- Remove badge_color from achievements seed/upsert (column doesn't exist)
- Rename name→title, add slug field in achievements seed
- Remove email from all user_profiles select queries (column doesn't exist)
- Fix email-based achievement target lookup via auth.admin.listUsers
- Add GET /api/projects/:projectId endpoint
- Fix import.meta.dirname → fileURLToPath for Node 18 compatibility
- Expose VITE_APP_VERSION from package.json at build time

Navigation systems
- DevPlatformNav: reorganize into Learn/Build grouped dropdowns with descriptions
- Migrate all 11 dev-platform pages from main Layout to DevPlatformLayout
- Remove dead isDevMode context nav swap from main Layout
- EthosLayout: purple-accented tab bar (Library, Artists, Licensing, Settings)
  with member-only gating and guest CTA — migrate 4 Ethos pages
- GameForgeLayout: orange-branded sidebar with Studio section and lock icons
  for unauthenticated users — migrate GameForge + GameForgeDashboard
- SysBar: live latency ping, status dot (green/yellow/red), real version

Layout dropdown
- Role-gate Admin (owner/admin/founder only) and Internal Docs (+ staff)
- Add Internal section label with separator
- Fix settings link from /dashboard?tab=profile#settings to /dashboard?tab=settings

Project pages
- Add ProjectDetail page at /projects/:projectId
- Fix ProfilePassport "View mission" link from /projects/new to /projects/:id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 05:01:10 +00:00

299 lines
11 KiB
TypeScript

import { useEffect, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import EthosLayout from "@/components/ethos/EthosLayout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
// API Base URL for fetch requests
const API_BASE = import.meta.env.VITE_API_BASE || "";
import { Star, Mail, Music, Zap, Clock } from "lucide-react";
import { resolveIdentifierToUserId, isUUID } from "@/lib/identifier-resolver";
interface Artist {
user_id: string;
skills: string[];
for_hire: boolean;
bio?: string;
portfolio_url?: string;
sample_price_track?: number;
sample_price_sfx?: number;
sample_price_score?: number;
turnaround_days?: number;
verified: boolean;
total_downloads: number;
created_at: string;
user_profiles: {
id: string;
full_name: string;
avatar_url?: string;
email?: string;
};
tracks: Array<{
id: string;
title: string;
genre: string[];
download_count: number;
}>;
}
export default function ArtistProfile() {
const { userId: identifier } = useParams<{ userId: string }>();
const navigate = useNavigate();
const [artist, setArtist] = useState<Artist | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchArtist = async () => {
if (!identifier) return;
try {
// Support both username and userId
let resolvedUserId = identifier;
if (!isUUID(identifier)) {
// If not a UUID, try to resolve username to userId
const userId = await resolveIdentifierToUserId(identifier);
if (userId) {
resolvedUserId = userId;
} else {
// If resolution failed and it's not a UUID, it's an invalid identifier
setArtist(null);
setLoading(false);
return;
}
}
const res = await fetch(
`${API_BASE}/api/ethos/artists?id=${resolvedUserId}`,
);
const data = await res.json();
setArtist(data);
} catch (error) {
console.error("Failed to fetch artist:", error);
setArtist(null);
} finally {
setLoading(false);
}
};
fetchArtist();
}, [identifier]);
if (loading) {
return (
<EthosLayout>
<div className="py-20 text-center">Loading artist profile...</div>
</EthosLayout>
);
}
if (!artist) {
return (
<EthosLayout>
<div className="py-20 text-center">Artist not found</div>
</EthosLayout>
);
}
const memberSince = new Date(artist.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
});
return (
<>
<SEO
pageTitle={`${artist.user_profiles.full_name} - Ethos Guild Artist`}
description={artist.bio || "Ethos Guild artist profile"}
/>
<EthosLayout>
<div className="bg-slate-950 text-foreground min-h-screen">
{/* Profile Header */}
<section className="border-b border-slate-800 bg-gradient-to-b from-slate-900 to-slate-950 py-12">
<div className="container mx-auto px-4 max-w-4xl">
<div className="flex flex-col md:flex-row gap-8 items-start">
<Avatar className="h-24 w-24 rounded-lg">
<AvatarImage src={artist.user_profiles.avatar_url} />
<AvatarFallback className="bg-slate-800 text-xl">
{artist.user_profiles.full_name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">
{artist.user_profiles.full_name}
</h1>
{artist.verified && (
<Badge className="bg-gradient-to-r from-blue-600 to-cyan-600">
Verified Artist
</Badge>
)}
</div>
{artist.bio && (
<p className="text-slate-300 mb-4">{artist.bio}</p>
)}
<div className="flex flex-wrap gap-6 mb-6 text-sm">
<div>
<p className="text-slate-500">Total Downloads</p>
<p className="text-xl font-bold text-white">
{artist.total_downloads}
</p>
</div>
<div>
<p className="text-slate-500">Tracks Published</p>
<p className="text-xl font-bold text-white">
{artist.tracks.length}
</p>
</div>
<div>
<p className="text-slate-500">Member Since</p>
<p className="text-xl font-bold text-white">
{memberSince}
</p>
</div>
</div>
{artist.for_hire && (
<Button className="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-700 hover:to-purple-700">
<Mail className="h-4 w-4 mr-2" />
Contact for Commission
</Button>
)}
</div>
</div>
{/* Skills & Services */}
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
{artist.skills.length > 0 && (
<Card className="bg-slate-900/50 border-slate-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Zap className="h-5 w-5" />
Skills
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{artist.skills.map((skill) => (
<Badge
key={skill}
variant="secondary"
className="bg-slate-800"
>
{skill}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{artist.for_hire && (
<Card className="bg-slate-900/50 border-slate-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Music className="h-5 w-5" />
Services
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{artist.sample_price_track && (
<div className="flex justify-between">
<span className="text-slate-300">Custom Track</span>
<span className="text-white font-semibold">
${artist.sample_price_track}
</span>
</div>
)}
{artist.sample_price_sfx && (
<div className="flex justify-between">
<span className="text-slate-300">SFX Pack</span>
<span className="text-white font-semibold">
${artist.sample_price_sfx}
</span>
</div>
)}
{artist.sample_price_score && (
<div className="flex justify-between">
<span className="text-slate-300">Full Score</span>
<span className="text-white font-semibold">
${artist.sample_price_score}
</span>
</div>
)}
{artist.turnaround_days && (
<div className="pt-2 border-t border-slate-800 flex items-center gap-2 text-sm text-slate-400">
<Clock className="h-4 w-4" />
{artist.turnaround_days} day turnaround
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</section>
{/* Portfolio */}
<section className="py-12">
<div className="container mx-auto px-4 max-w-4xl">
<h2 className="text-2xl font-bold text-white mb-6">Portfolio</h2>
{artist.tracks.length === 0 ? (
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="py-12 text-center text-slate-400">
No tracks published yet
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{artist.tracks.map((track) => (
<Card
key={track.id}
className="bg-slate-900/50 border-slate-800 hover:border-slate-700 transition"
>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex-1">
<h3 className="text-white font-semibold">
{track.title}
</h3>
<div className="flex gap-2 mt-1">
{track.genre.map((g) => (
<Badge
key={g}
variant="secondary"
className="bg-slate-800 text-xs"
>
{g}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2 text-slate-400 text-sm">
<Music className="h-4 w-4" />
{track.download_count} downloads
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</section>
</div>
</EthosLayout>
</>
);
}