aethex-forge/client/pages/ProjectDetail.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

280 lines
9.4 KiB
TypeScript

import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import Layout from "@/components/Layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Github,
ExternalLink,
LayoutDashboard,
Calendar,
Cpu,
Activity,
} from "lucide-react";
const API_BASE = import.meta.env.VITE_API_BASE || "";
interface Project {
id: string;
title: string;
description?: string | null;
status?: string | null;
technologies?: string[] | null;
github_url?: string | null;
live_url?: string | null;
image_url?: string | null;
engine?: string | null;
priority?: string | null;
progress?: number | null;
created_at?: string | null;
updated_at?: string | null;
}
interface Owner {
id: string;
username?: string | null;
full_name?: string | null;
avatar_url?: string | null;
}
const STATUS_COLORS: Record<string, string> = {
planning: "bg-slate-500/20 text-slate-300 border-slate-600",
in_progress: "bg-blue-500/20 text-blue-300 border-blue-600",
completed: "bg-green-500/20 text-green-300 border-green-600",
on_hold: "bg-yellow-500/20 text-yellow-300 border-yellow-600",
};
const STATUS_LABELS: Record<string, string> = {
planning: "Planning",
in_progress: "In Progress",
completed: "Completed",
on_hold: "On Hold",
};
const formatDate = (v?: string | null) => {
if (!v) return null;
const d = new Date(v);
if (isNaN(d.getTime())) return null;
return d.toLocaleDateString(undefined, { dateStyle: "medium" });
};
export default function ProjectDetail() {
const { projectId } = useParams<{ projectId: string }>();
const [project, setProject] = useState<Project | null>(null);
const [owner, setOwner] = useState<Owner | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!projectId) return;
setLoading(true);
fetch(`${API_BASE}/api/projects/${projectId}`)
.then((r) => {
if (r.status === 404) { setNotFound(true); return null; }
return r.json();
})
.then((body) => {
if (!body) return;
setProject(body.project);
setOwner(body.owner);
})
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [projectId]);
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-pulse text-slate-400">Loading project</div>
</div>
</Layout>
);
}
if (notFound || !project) {
return (
<Layout>
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<p className="text-xl text-slate-300">Project not found.</p>
<Button asChild variant="outline">
<Link to="/projects">Browse projects</Link>
</Button>
</div>
</Layout>
);
}
const statusKey = project.status ?? "planning";
const statusClass = STATUS_COLORS[statusKey] ?? STATUS_COLORS.planning;
const statusLabel = STATUS_LABELS[statusKey] ?? statusKey;
const ownerSlug = owner?.username ?? owner?.id;
const ownerName = owner?.full_name || owner?.username || "Unknown";
const ownerInitials = ownerName
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<Layout>
<div className="max-w-4xl mx-auto px-4 py-10 space-y-8">
{/* Header */}
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge className={`text-xs border ${statusClass}`}>
{statusLabel}
</Badge>
{project.priority && (
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{project.priority} priority
</Badge>
)}
</div>
<h1 className="text-3xl font-bold text-white">{project.title}</h1>
{project.description && (
<p className="text-slate-300 leading-relaxed text-base max-w-2xl">
{project.description}
</p>
)}
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link to={`/projects/${project.id}/board`}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Project Board
</Link>
</Button>
{project.github_url && (
<Button asChild variant="outline">
<a href={project.github_url} target="_blank" rel="noopener noreferrer">
<Github className="mr-2 h-4 w-4" />
Repository
</a>
</Button>
)}
{project.live_url && (
<Button asChild variant="outline">
<a href={project.live_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Live
</a>
</Button>
)}
</div>
<Separator className="border-slate-700" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Meta card */}
<Card className="bg-slate-900/60 border-slate-700 md:col-span-1 space-y-0">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-400 uppercase tracking-wide">
Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{owner && (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={owner.avatar_url ?? undefined} />
<AvatarFallback className="bg-slate-700 text-slate-300 text-xs">
{ownerInitials}
</AvatarFallback>
</Avatar>
<div>
<p className="text-slate-400 text-xs">Owner</p>
{ownerSlug ? (
<Link
to={`/u/${ownerSlug}`}
className="text-aethex-300 hover:underline font-medium"
>
{ownerName}
</Link>
) : (
<span className="text-slate-200">{ownerName}</span>
)}
</div>
</div>
)}
{project.engine && (
<div className="flex items-start gap-2 text-slate-300">
<Cpu className="h-4 w-4 mt-0.5 text-slate-500 shrink-0" />
<div>
<p className="text-slate-400 text-xs">Engine</p>
<p>{project.engine}</p>
</div>
</div>
)}
{typeof project.progress === "number" && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-slate-400 text-xs">
<Activity className="h-3.5 w-3.5" />
<span>Progress {project.progress}%</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-1.5">
<div
className="bg-aethex-500 h-1.5 rounded-full transition-all"
style={{ width: `${Math.min(100, project.progress)}%` }}
/>
</div>
</div>
)}
{(project.created_at || project.updated_at) && (
<div className="flex items-start gap-2 text-slate-300">
<Calendar className="h-4 w-4 mt-0.5 text-slate-500 shrink-0" />
<div className="space-y-0.5">
{project.created_at && (
<p className="text-xs text-slate-400">
Created {formatDate(project.created_at)}
</p>
)}
{project.updated_at && (
<p className="text-xs text-slate-400">
Updated {formatDate(project.updated_at)}
</p>
)}
</div>
</div>
)}
</CardContent>
</Card>
{/* Technologies */}
{project.technologies && project.technologies.length > 0 && (
<Card className="bg-slate-900/60 border-slate-700 md:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-400 uppercase tracking-wide">
Technologies
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{project.technologies.map((tech) => (
<Badge
key={tech}
variant="outline"
className="border-slate-600 text-slate-300 text-xs"
>
{tech}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</Layout>
);
}