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
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>
280 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|