Initial Phoenix Sankofa Cloud setup
- Complete project structure with Next.js frontend - GraphQL API backend with Apollo Server - Portal application with NextAuth - Crossplane Proxmox provider - GitOps configurations - CI/CD pipelines - Testing infrastructure (Vitest, Jest, Go tests) - Error handling and monitoring - Security hardening - UI component library - Documentation
This commit is contained in:
140
src/app/about/page.tsx
Normal file
140
src/app/about/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-studio-black">
|
||||
{/* Header */}
|
||||
<header className="border-b border-studio-medium bg-studio-dark">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<Link href="/" className="text-2xl font-bold text-white">
|
||||
Phoenix <span className="text-phoenix-fire">Sankofa</span> Cloud
|
||||
</Link>
|
||||
<nav className="flex gap-4">
|
||||
<Link href="/" className="text-gray-400 hover:text-white">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/products" className="text-gray-400 hover:text-white">
|
||||
Products
|
||||
</Link>
|
||||
<Link href="/manifesto" className="text-gray-400 hover:text-white">
|
||||
Manifesto
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-24 px-4">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h1 className="mb-6 text-5xl font-bold text-white md:text-6xl">
|
||||
The Deeper Meaning
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300">
|
||||
Understanding the Akan philosophy of Sankofa and the Phoenix transformation
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Philosophy Section */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-4xl space-y-12">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-phoenix-fire">Sankofa and Akan Cosmology</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-300">
|
||||
<p>
|
||||
In Akan cosmology, the universe is circular, not linear; interconnected, not hierarchical;
|
||||
ancestrally governed, not isolated. Sankofa represents the return to origin to complete the cycle.
|
||||
</p>
|
||||
<p>
|
||||
It echoes the Akan belief that life, identity, and destiny (nkrabea) must be aligned with
|
||||
source (kra), ancestral memory (nananom nsamanfoɔ), and lineage (abusua).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sankofa-gold">Sankofa and Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-300">
|
||||
<p>
|
||||
Western time is linear. Akan time is recursive — events are patterns that return.
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
Progress is a spiral, not a straight line.
|
||||
</p>
|
||||
<p>
|
||||
The future is found in the past; the past informs the future. This is why a bird looking
|
||||
back while flying forward is perfect as the symbol.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-neon-cyan">Sankofa and the Soul</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-300">
|
||||
<p>
|
||||
In Akan thought, "Kra" is the divine soul, given by Nyame (Creator). It comes from a
|
||||
spiritual origin place, and its mission is learned by reflecting on where it came from.
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
Sankofa is the soul's act of remembering itself.
|
||||
</p>
|
||||
<p>It is spiritual self-retrieval.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-phoenix-fire">Sankofa as Technology</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-300">
|
||||
<p>
|
||||
Sankofa is an algorithm. A cosmological protocol. A recursive pattern of:
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-center text-white">
|
||||
Remember → Retrieve → Restore → Rise
|
||||
</p>
|
||||
<p>
|
||||
Sankofa is how individuals, families, nations, and civilizations rebuild themselves.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sankofa-gold">The Phoenix–Sankofa Integration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-gray-300">
|
||||
<p>
|
||||
<strong className="text-white">Phoenix</strong> = fire, rebirth, immortality
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-white">Sankofa</strong> = return, reclaim, rise forward
|
||||
</p>
|
||||
<p className="text-xl font-semibold text-center text-white mt-6">
|
||||
Together, they form: <span className="text-phoenix-fire">Rebirth + Ancestral Return = Sovereign Global Power</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<Link href="/manifesto">
|
||||
<Button variant="phoenix" size="lg">Read the Full Manifesto</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
60
src/app/globals.css
Normal file
60
src/app/globals.css
Normal file
@@ -0,0 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 10 10 10;
|
||||
--foreground: 255 255 255;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-studio-medium;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-studio-black text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.glow-phoenix {
|
||||
box-shadow: 0 0 20px rgba(255, 69, 0, 0.5);
|
||||
}
|
||||
|
||||
.glow-sankofa {
|
||||
box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.glow-neon {
|
||||
box-shadow: 0 0 20px rgba(0, 255, 209, 0.5);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.sr-only-focusable:focus {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: inherit;
|
||||
margin: inherit;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
32
src/app/layout.tsx
Normal file
32
src/app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Phoenix Sankofa Cloud',
|
||||
description: 'The sovereign cloud born of fire and ancestral wisdom.',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ErrorBoundary>
|
||||
<Providers>{children}</Providers>
|
||||
</ErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
167
src/app/manifesto/page.tsx
Normal file
167
src/app/manifesto/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function ManifestoPage() {
|
||||
const principles = [
|
||||
{
|
||||
title: 'Sovereignty',
|
||||
description: 'Infrastructure must serve sovereignty, not subvert it. Every region, nation, and community has the right to control its own data, govern its own infrastructure, and determine its own technological destiny.',
|
||||
},
|
||||
{
|
||||
title: 'Ancestral Wisdom',
|
||||
description: 'Technology must remember its origins. The cloud must learn from the past, honor ancestral knowledge, and integrate traditional wisdom with modern innovation.',
|
||||
},
|
||||
{
|
||||
title: 'Identity',
|
||||
description: 'Infrastructure must reflect identity. Technology is not neutral. We build infrastructure that reflects our cultural identity, honors our heritage, and serves our communities.',
|
||||
},
|
||||
{
|
||||
title: 'Recursive Learning',
|
||||
description: 'Progress is a spiral, not a straight line. We build systems that remember what came before, learn from history, and return to origin to complete cycles.',
|
||||
},
|
||||
{
|
||||
title: 'Global Cultural Intelligence',
|
||||
description: 'The cloud must understand culture. Across 325 regions, we build infrastructure that respects cultural contexts, adapts to local needs, and honors diverse traditions.',
|
||||
},
|
||||
{
|
||||
title: 'Rebirth and Transformation',
|
||||
description: 'Like the Phoenix, we rise from fire. We build infrastructure that transforms continuously, renews itself, and emerges stronger from every cycle.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-studio-black">
|
||||
{/* Header */}
|
||||
<header className="border-b border-studio-medium bg-studio-dark">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<Link href="/" className="text-2xl font-bold text-white">
|
||||
Phoenix <span className="text-phoenix-fire">Sankofa</span> Cloud
|
||||
</Link>
|
||||
<nav className="flex gap-4">
|
||||
<Link href="/" className="text-gray-400 hover:text-white">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/about" className="text-gray-400 hover:text-white">
|
||||
About
|
||||
</Link>
|
||||
<Link href="/products" className="text-gray-400 hover:text-white">
|
||||
Products
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-24 px-4">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h1 className="mb-6 text-5xl font-bold text-white md:text-6xl">
|
||||
The Sovereign Cloud Manifesto
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300">
|
||||
A declaration of technological sovereignty
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Declaration */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Card className="border-phoenix-fire/50 bg-gradient-to-br from-phoenix-fire/10 to-sankofa-gold/10">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4 text-lg text-white">
|
||||
<p className="text-2xl font-bold">We declare the right to technological sovereignty.</p>
|
||||
<p className="text-2xl font-bold">We declare the right to infrastructure that reflects our identity.</p>
|
||||
<p className="text-2xl font-bold">We declare the right to cloud computing that honors our ancestors.</p>
|
||||
<p className="text-2xl font-bold">We declare the right to AI that remembers where it came from.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Principles */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 className="mb-12 text-center text-4xl font-bold text-white">
|
||||
Principles
|
||||
</h2>
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
{principles.map((principle) => (
|
||||
<Card key={principle.title}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-phoenix-fire">{principle.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-300">{principle.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* The Promise */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl text-center text-sankofa-gold">The Promise</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<p className="text-center text-2xl font-bold text-white">
|
||||
We promise infrastructure that:
|
||||
</p>
|
||||
<div className="space-y-4 text-lg text-gray-300">
|
||||
<p className="flex items-center gap-3">
|
||||
<span className="text-phoenix-fire text-2xl">→</span>
|
||||
<strong className="text-white">Remembers</strong> its origins
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<span className="text-phoenix-fire text-2xl">→</span>
|
||||
<strong className="text-white">Retrieves</strong> ancestral wisdom
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<span className="text-phoenix-fire text-2xl">→</span>
|
||||
<strong className="text-white">Restores</strong> identity and sovereignty
|
||||
</p>
|
||||
<p className="flex items-center gap-3">
|
||||
<span className="text-phoenix-fire text-2xl">→</span>
|
||||
<strong className="text-white">Rises</strong> forward with purpose
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-8 text-center text-3xl font-bold text-white">
|
||||
Remember → Retrieve → Restore → Rise
|
||||
</p>
|
||||
<p className="text-center text-gray-400">
|
||||
This is the Sankofa cycle. This is the Phoenix transformation.
|
||||
</p>
|
||||
<p className="text-center text-2xl font-bold text-phoenix-fire">
|
||||
This is Phoenix Sankofa Cloud.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h2 className="mb-6 text-3xl font-bold text-white">
|
||||
Join the Movement
|
||||
</h2>
|
||||
<p className="mb-8 text-xl text-gray-400">
|
||||
If you believe in technological sovereignty, ancestral wisdom, and identity-based infrastructure,
|
||||
</p>
|
||||
<p className="mb-8 text-xl font-semibold text-white">
|
||||
Join us. Build with us. Rise with us.
|
||||
</p>
|
||||
<Link href="/">
|
||||
<Button variant="phoenix" size="lg">Get Started</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
34
src/app/page.test.tsx
Normal file
34
src/app/page.test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@/lib/test-utils'
|
||||
import Home from './page'
|
||||
|
||||
describe('Home Page', () => {
|
||||
it('should render the main heading', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByRole('heading', { level: 1, name: /phoenix.*sankofa.*cloud/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the tagline', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByText(/the sovereign cloud born of fire and ancestral wisdom/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the Remember → Retrieve → Restore → Rise text', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByText(/remember.*retrieve.*restore.*rise/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render navigation links', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByRole('link', { name: /learn more/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /products/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render feature cards', () => {
|
||||
render(<Home />)
|
||||
expect(screen.getByText(/phoenix fire/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/sankofa memory/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/sovereign power/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
110
src/app/page.tsx
Normal file
110
src/app/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen bg-studio-black">
|
||||
{/* Hero Section */}
|
||||
<section className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-phoenix-fire/20 via-transparent to-sankofa-gold/20" />
|
||||
|
||||
<div className="relative z-10 max-w-4xl text-center">
|
||||
<h1 className="mb-6 text-5xl font-bold tracking-tight text-white md:text-7xl">
|
||||
Phoenix{' '}
|
||||
<span className="bg-gradient-to-r from-phoenix-fire to-sankofa-gold bg-clip-text text-transparent">
|
||||
Sankofa
|
||||
</span>{' '}
|
||||
Cloud
|
||||
</h1>
|
||||
|
||||
<p className="mb-8 text-xl text-gray-300 md:text-2xl">
|
||||
The sovereign cloud born of fire and ancestral wisdom.
|
||||
</p>
|
||||
|
||||
<p className="mb-12 text-lg text-gray-400">
|
||||
Remember → Retrieve → Restore → Rise
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:justify-center">
|
||||
<Link href="/about">
|
||||
<Button variant="phoenix" size="lg">Learn More</Button>
|
||||
</Link>
|
||||
<Link href="/products">
|
||||
<Button variant="outline" size="lg">Products</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-24 px-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 className="mb-12 text-center text-4xl font-bold text-white">
|
||||
Sovereign. Ancestral. Transformative.
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-phoenix-fire">Phoenix Fire</CardTitle>
|
||||
<CardDescription>
|
||||
Rebirth through transformation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-300">
|
||||
Infrastructure that continuously transforms and renews, rising from every challenge like the Phoenix.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sankofa-gold">Sankofa Memory</CardTitle>
|
||||
<CardDescription>
|
||||
Return to origin, rise forward
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-300">
|
||||
Systems that remember their origins, retrieve ancestral wisdom, and rise forward with purpose.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-neon-cyan">Sovereign Power</CardTitle>
|
||||
<CardDescription>
|
||||
True technological sovereignty
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-300">
|
||||
Complete control over infrastructure, data, and destiny across 325 global regions.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24 px-4">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h2 className="mb-6 text-4xl font-bold text-white">
|
||||
Join the Sovereign Cloud Movement
|
||||
</h2>
|
||||
<p className="mb-8 text-xl text-gray-400">
|
||||
Build infrastructure that honors identity and serves sovereignty.
|
||||
</p>
|
||||
<Link href="/manifesto">
|
||||
<Button variant="phoenix" size="lg">Read the Manifesto</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
134
src/app/products/page.tsx
Normal file
134
src/app/products/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function ProductsPage() {
|
||||
const products = [
|
||||
{
|
||||
category: 'Compute',
|
||||
items: [
|
||||
{ name: 'PhoenixCore Compute', description: 'Core compute engine powered by Phoenix fire' },
|
||||
{ name: 'SankofaEdge Nodes', description: 'Edge nodes that remember and return data' },
|
||||
{ name: 'AkanFire VM Engine', description: 'High-performance VMs with Akan heritage' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Storage',
|
||||
items: [
|
||||
{ name: 'OkraVault Storage', description: 'Storage for the soul of your data' },
|
||||
{ name: 'Nananom Archive', description: 'Long-term archival with ancestral memory' },
|
||||
{ name: 'Egg of the Phoenix Object Store', description: 'Object storage for transformation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Networking',
|
||||
items: [
|
||||
{ name: 'SankofaGrid Global Mesh', description: 'Global network mesh that remembers' },
|
||||
{ name: 'AkanSphere Edge Routing', description: 'Edge routing with cultural intelligence' },
|
||||
{ name: 'PhoenixFlight Network Fabric', description: 'High-performance network fabric' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'AI & Machine Learning',
|
||||
items: [
|
||||
{ name: 'Firebird AI Engine', description: 'AI that transforms like fire' },
|
||||
{ name: 'Sankofa Memory Model', description: 'AI models that remember and learn' },
|
||||
{ name: 'Ancestral Neural Fabric', description: 'Distributed AI with ancestral patterns' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Security',
|
||||
items: [
|
||||
{ name: 'Aegis of Akan Shield', description: 'Comprehensive security platform' },
|
||||
{ name: 'PhoenixGuard IAM', description: 'Identity and access management' },
|
||||
{ name: 'Nsamankom Sentinel', description: 'Security monitoring with ancestral protection' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Identity',
|
||||
items: [
|
||||
{ name: 'OkraID', description: 'Soul-powered identity framework' },
|
||||
{ name: 'AkanAuth Sovereign Identity Plane', description: 'Sovereign authentication platform' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-studio-black">
|
||||
{/* Header */}
|
||||
<header className="border-b border-studio-medium bg-studio-dark">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<Link href="/" className="text-2xl font-bold text-white">
|
||||
Phoenix <span className="text-phoenix-fire">Sankofa</span> Cloud
|
||||
</Link>
|
||||
<nav className="flex gap-4">
|
||||
<Link href="/" className="text-gray-400 hover:text-white">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/about" className="text-gray-400 hover:text-white">
|
||||
About
|
||||
</Link>
|
||||
<Link href="/manifesto" className="text-gray-400 hover:text-white">
|
||||
Manifesto
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-24 px-4">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h1 className="mb-6 text-5xl font-bold text-white md:text-6xl">
|
||||
Product Architecture
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300">
|
||||
Infrastructure services themed in Phoenix + Akan cosmology
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Products Grid */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-7xl space-y-16">
|
||||
{products.map((category) => (
|
||||
<div key={category.category}>
|
||||
<h2 className="mb-8 text-3xl font-bold text-white">
|
||||
{category.category}
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{category.items.map((item) => (
|
||||
<Card key={item.name}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{item.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-gray-400">
|
||||
{item.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h2 className="mb-6 text-3xl font-bold text-white">
|
||||
Ready to Build?
|
||||
</h2>
|
||||
<p className="mb-8 text-xl text-gray-400">
|
||||
Start your sovereign cloud journey today.
|
||||
</p>
|
||||
<Link href="/manifesto">
|
||||
<Button variant="phoenix" size="lg">Learn More</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
46
src/app/providers.tsx
Normal file
46
src/app/providers.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { ApolloProvider } from '@apollo/client'
|
||||
import { apolloClient } from '@/lib/graphql/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on 4xx errors
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) {
|
||||
const statusCode = error.statusCode as number
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return failureCount < 3
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</ApolloProvider>
|
||||
)
|
||||
}
|
||||
|
||||
135
src/components/3d/NetworkTopology.tsx
Normal file
135
src/components/3d/NetworkTopology.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useMemo } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Mesh, Vector3 } from 'three'
|
||||
import * as THREE from 'three'
|
||||
|
||||
interface Node {
|
||||
id: string
|
||||
name: string
|
||||
position: [number, number, number]
|
||||
health: number
|
||||
type: 'region' | 'cluster' | 'service'
|
||||
}
|
||||
|
||||
interface Edge {
|
||||
from: string
|
||||
to: string
|
||||
latency?: number
|
||||
bandwidth?: number
|
||||
}
|
||||
|
||||
interface NetworkTopologyProps {
|
||||
nodes?: Node[]
|
||||
edges?: Edge[]
|
||||
}
|
||||
|
||||
export default function NetworkTopology({
|
||||
nodes = defaultNodes,
|
||||
edges = defaultEdges
|
||||
}: NetworkTopologyProps) {
|
||||
const nodeRefs = useRef<{ [key: string]: Mesh }>({})
|
||||
|
||||
useFrame((state) => {
|
||||
// Animate nodes
|
||||
Object.values(nodeRefs.current).forEach((node, index) => {
|
||||
if (node) {
|
||||
node.rotation.y += 0.005
|
||||
const time = state.clock.elapsedTime
|
||||
node.position.y = nodes[index]?.position[1] + Math.sin(time + index) * 0.1
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const nodePositions = useMemo(() => {
|
||||
return nodes.map((node) => new Vector3(...node.position))
|
||||
}, [nodes])
|
||||
|
||||
const getNodeColor = (health: number, type: string) => {
|
||||
if (health >= 90) return '#00FF88'
|
||||
if (health >= 70) return '#FFB800'
|
||||
if (health >= 50) return '#FF8C00'
|
||||
return '#FF0040'
|
||||
}
|
||||
|
||||
const getNodeSize = (type: string) => {
|
||||
switch (type) {
|
||||
case 'region': return 0.3
|
||||
case 'cluster': return 0.2
|
||||
case 'service': return 0.15
|
||||
default: return 0.2
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Render edges/connections */}
|
||||
{edges.map((edge, index) => {
|
||||
const fromNode = nodes.find((n) => n.id === edge.from)
|
||||
const toNode = nodes.find((n) => n.id === edge.to)
|
||||
|
||||
if (!fromNode || !toNode) return null
|
||||
|
||||
const from = new Vector3(...fromNode.position)
|
||||
const to = new Vector3(...toNode.position)
|
||||
|
||||
return (
|
||||
<line key={`edge-${index}`}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={2}
|
||||
array={new Float32Array([
|
||||
from.x, from.y, from.z,
|
||||
to.x, to.y, to.z,
|
||||
])}
|
||||
itemSize={3}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color="#00FFD1" opacity={0.3} transparent />
|
||||
</line>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Render nodes */}
|
||||
{nodes.map((node, index) => (
|
||||
<mesh
|
||||
key={node.id}
|
||||
ref={(ref) => {
|
||||
if (ref) nodeRefs.current[node.id] = ref
|
||||
}}
|
||||
position={node.position}
|
||||
>
|
||||
<sphereGeometry args={[getNodeSize(node.type), 16, 16]} />
|
||||
<meshStandardMaterial
|
||||
color={getNodeColor(node.health, node.type)}
|
||||
emissive={getNodeColor(node.health, node.type)}
|
||||
emissiveIntensity={0.3}
|
||||
metalness={0.4}
|
||||
roughness={0.3}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
// Default data for demonstration
|
||||
const defaultNodes: Node[] = [
|
||||
{ id: '1', name: 'Region 1', position: [-2, 0, 0], health: 95, type: 'region' },
|
||||
{ id: '2', name: 'Region 2', position: [2, 0, 0], health: 88, type: 'region' },
|
||||
{ id: '3', name: 'Cluster 1', position: [-1, 1, 0], health: 92, type: 'cluster' },
|
||||
{ id: '4', name: 'Cluster 2', position: [1, 1, 0], health: 85, type: 'cluster' },
|
||||
{ id: '5', name: 'Service 1', position: [-1.5, -1, 0], health: 90, type: 'service' },
|
||||
{ id: '6', name: 'Service 2', position: [1.5, -1, 0], health: 75, type: 'service' },
|
||||
]
|
||||
|
||||
const defaultEdges: Edge[] = [
|
||||
{ from: '1', to: '2' },
|
||||
{ from: '1', to: '3' },
|
||||
{ from: '2', to: '4' },
|
||||
{ from: '3', to: '5' },
|
||||
{ from: '4', to: '6' },
|
||||
]
|
||||
|
||||
38
src/components/3d/NetworkTopologyView.tsx
Normal file
38
src/components/3d/NetworkTopologyView.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { Canvas } from '@react-three/fiber'
|
||||
import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei'
|
||||
import { Suspense } from 'react'
|
||||
import NetworkTopology from './NetworkTopology'
|
||||
|
||||
export default function NetworkTopologyView() {
|
||||
return (
|
||||
<div className="h-[600px] w-full rounded-lg border border-studio-medium bg-studio-black">
|
||||
<Canvas
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
dpr={[1, 2]}
|
||||
camera={{ position: [0, 0, 8], fov: 50 }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<PerspectiveCamera makeDefault position={[0, 0, 8]} />
|
||||
<ambientLight intensity={0.3} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||
<pointLight position={[-10, -10, -10]} intensity={0.5} color="#00FFD1" />
|
||||
|
||||
<NetworkTopology />
|
||||
|
||||
<OrbitControls
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
minDistance={5}
|
||||
maxDistance={15}
|
||||
/>
|
||||
|
||||
<Environment preset="night" />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
92
src/components/3d/PhoenixSankofaModel.tsx
Normal file
92
src/components/3d/PhoenixSankofaModel.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Mesh } from 'three'
|
||||
import * as THREE from 'three'
|
||||
|
||||
export default function PhoenixSankofaModel() {
|
||||
const phoenixRef = useRef<Mesh>(null)
|
||||
const sankofaRef = useRef<Mesh>(null)
|
||||
|
||||
// Animate the Phoenix (fire sphere)
|
||||
useFrame((state) => {
|
||||
if (phoenixRef.current) {
|
||||
phoenixRef.current.rotation.y += 0.01
|
||||
phoenixRef.current.position.y = Math.sin(state.clock.elapsedTime) * 0.2
|
||||
}
|
||||
if (sankofaRef.current) {
|
||||
sankofaRef.current.rotation.y -= 0.01
|
||||
sankofaRef.current.position.y = Math.sin(state.clock.elapsedTime + Math.PI) * 0.2
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Phoenix Fire - Red/Orange sphere */}
|
||||
<mesh ref={phoenixRef} position={[-1, 0, 0]}>
|
||||
<sphereGeometry args={[0.8, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#FF4500"
|
||||
emissive="#FF4500"
|
||||
emissiveIntensity={0.5}
|
||||
metalness={0.3}
|
||||
roughness={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Sankofa Gold - Gold sphere */}
|
||||
<mesh ref={sankofaRef} position={[1, 0, 0]}>
|
||||
<sphereGeometry args={[0.8, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#FFD700"
|
||||
emissive="#FFD700"
|
||||
emissiveIntensity={0.3}
|
||||
metalness={0.5}
|
||||
roughness={0.2}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Connection line between Phoenix and Sankofa */}
|
||||
<line>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={2}
|
||||
array={new Float32Array([-1, 0, 0, 1, 0, 0])}
|
||||
itemSize={3}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color="#00FFD1" linewidth={2} />
|
||||
</line>
|
||||
|
||||
{/* Central node representing the integration */}
|
||||
<mesh position={[0, 0, 0]}>
|
||||
<torusGeometry args={[0.3, 0.1, 16, 32]} />
|
||||
<meshStandardMaterial
|
||||
color="#6A0DAD"
|
||||
emissive="#6A0DAD"
|
||||
emissiveIntensity={0.4}
|
||||
metalness={0.6}
|
||||
roughness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Particle effects around the model */}
|
||||
<points>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={100}
|
||||
array={new Float32Array(
|
||||
Array.from({ length: 300 }, () => (Math.random() - 0.5) * 4)
|
||||
)}
|
||||
itemSize={3}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial size={0.05} color="#00FFFF" />
|
||||
</points>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
38
src/components/3d/PhoenixSankofaScene.tsx
Normal file
38
src/components/3d/PhoenixSankofaScene.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { Canvas } from '@react-three/fiber'
|
||||
import { OrbitControls, Environment, PerspectiveCamera } from '@react-three/drei'
|
||||
import { Suspense } from 'react'
|
||||
import PhoenixSankofaModel from './PhoenixSankofaModel'
|
||||
|
||||
export default function PhoenixSankofaScene() {
|
||||
return (
|
||||
<div className="h-screen w-full bg-studio-black">
|
||||
<Canvas
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
dpr={[1, 2]}
|
||||
camera={{ position: [0, 0, 5], fov: 50 }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<PerspectiveCamera makeDefault position={[0, 0, 5]} />
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||
<pointLight position={[-10, -10, -10]} intensity={0.5} color="#FF4500" />
|
||||
|
||||
<PhoenixSankofaModel />
|
||||
|
||||
<OrbitControls
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
minDistance={3}
|
||||
maxDistance={10}
|
||||
/>
|
||||
|
||||
<Environment preset="night" />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
55
src/components/dashboards/Dashboard.tsx
Normal file
55
src/components/dashboards/Dashboard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import MetricsCard from './MetricsCard'
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricsCard
|
||||
title="Total Regions"
|
||||
value="325"
|
||||
health={95}
|
||||
trend="up"
|
||||
description="Active global regions"
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Active Services"
|
||||
value="1,247"
|
||||
health={88}
|
||||
trend="stable"
|
||||
description="Running services"
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Network Health"
|
||||
value="92%"
|
||||
health={92}
|
||||
trend="up"
|
||||
description="Overall network status"
|
||||
/>
|
||||
<MetricsCard
|
||||
title="Cost Efficiency"
|
||||
value="87%"
|
||||
health={87}
|
||||
trend="up"
|
||||
description="Cost optimization score"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-studio-medium bg-studio-dark p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold text-white">Performance Metrics</h2>
|
||||
<p className="text-gray-400">Chart placeholder - ECharts integration</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-studio-medium bg-studio-dark p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold text-white">Cost Analysis</h2>
|
||||
<p className="text-gray-400">Chart placeholder - ECharts integration</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
52
src/components/dashboards/MetricsCard.tsx
Normal file
52
src/components/dashboards/MetricsCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getHealthScoreColor } from '@/lib/design-system'
|
||||
|
||||
interface MetricsCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
health?: number
|
||||
trend?: 'up' | 'down' | 'stable'
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function MetricsCard({
|
||||
title,
|
||||
value,
|
||||
health,
|
||||
trend,
|
||||
description
|
||||
}: MetricsCardProps) {
|
||||
const healthColor = health !== undefined ? getHealthScoreColor(health) : undefined
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-400">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-white">{value}</span>
|
||||
{health !== undefined && (
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: healthColor }}
|
||||
>
|
||||
{health}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-gray-400">{description}</p>
|
||||
)}
|
||||
{trend && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Trend: {trend}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
44
src/components/data/avatar.tsx
Normal file
44
src/components/data/avatar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
src?: string
|
||||
alt?: string
|
||||
fallback?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-8 text-xs',
|
||||
md: 'h-10 w-10 text-sm',
|
||||
lg: 'h-12 w-12 text-base',
|
||||
}
|
||||
|
||||
export function Avatar({ src, alt, fallback, size = 'md', className, ...props }: AvatarProps) {
|
||||
const [error, setError] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-studio-medium',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{src && !error ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">
|
||||
{fallback || (alt ? alt.charAt(0).toUpperCase() : '?')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
33
src/components/data/badge.tsx
Normal file
33
src/components/data/badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-phoenix-fire focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-studio-medium bg-studio-medium text-foreground',
|
||||
success: 'border-status-success bg-status-success/10 text-status-success',
|
||||
error: 'border-status-error bg-status-error/10 text-status-error',
|
||||
warning: 'border-status-warning bg-status-warning/10 text-status-warning',
|
||||
info: 'border-status-info bg-status-info/10 text-status-info',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
28
src/components/data/tooltip.tsx
Normal file
28
src/components/data/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-studio-medium bg-studio-dark px-3 py-1.5 text-sm text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
77
src/components/editors/TopologyEditor.tsx
Normal file
77
src/components/editors/TopologyEditor.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
addEdge,
|
||||
Connection,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Background,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
const initialNodes: Node[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'input',
|
||||
data: { label: 'Region 1' },
|
||||
position: { x: 250, y: 5 },
|
||||
style: { background: '#1E3A8A', color: '#fff' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
data: { label: 'Cluster 1' },
|
||||
position: { x: 100, y: 100 },
|
||||
style: { background: '#FF4500', color: '#fff' },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
data: { label: 'Service 1' },
|
||||
position: { x: 400, y: 100 },
|
||||
style: { background: '#FFD700', color: '#000' },
|
||||
},
|
||||
]
|
||||
|
||||
const initialEdges: Edge[] = [
|
||||
{ id: 'e1-2', source: '1', target: '2' },
|
||||
{ id: 'e1-3', source: '1', target: '3' },
|
||||
]
|
||||
|
||||
export default function TopologyEditor() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-[600px] w-full rounded-lg border border-studio-medium bg-studio-dark">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
fitView
|
||||
className="bg-studio-black"
|
||||
>
|
||||
<Controls className="bg-studio-dark border-studio-medium" />
|
||||
<MiniMap
|
||||
className="bg-studio-dark border-studio-medium"
|
||||
nodeColor={(node) => {
|
||||
if (node.type === 'input') return '#1E3A8A'
|
||||
return '#FF4500'
|
||||
}}
|
||||
/>
|
||||
<Background color="#2A2A2A" gap={16} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
103
src/components/error-boundary.tsx
Normal file
103
src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
errorInfo: ErrorInfo | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Log error to monitoring service
|
||||
if (typeof window !== 'undefined') {
|
||||
const { logError } = require('@/lib/monitoring')
|
||||
logError(error, { errorInfo })
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
})
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return <ErrorFallback error={this.state.error} onReset={this.handleReset} />
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error: Error | null
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
function ErrorFallback({ error, onReset }: ErrorFallbackProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-studio-black p-4">
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-status-error">Something went wrong</CardTitle>
|
||||
<CardDescription>
|
||||
An unexpected error occurred. Please try again or contact support if the problem persists.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-studio-medium p-4">
|
||||
<p className="font-mono text-sm text-status-error">{error.message}</p>
|
||||
{process.env.NODE_ENV === 'development' && error.stack && (
|
||||
<pre className="mt-2 overflow-auto text-xs text-gray-400">{error.stack}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onReset} variant="phoenix">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
41
src/components/error-fallback.tsx
Normal file
41
src/components/error-fallback.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}
|
||||
|
||||
export function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-studio-black p-4">
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-status-error">Something went wrong</CardTitle>
|
||||
<CardDescription>
|
||||
An unexpected error occurred. Please try again or contact support if the problem persists.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-studio-medium p-4">
|
||||
<p className="font-mono text-sm text-status-error">{error.message}</p>
|
||||
{process.env.NODE_ENV === 'development' && error.stack && (
|
||||
<pre className="mt-2 overflow-auto text-xs text-gray-400">{error.stack}</pre>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={resetErrorBoundary} variant="phoenix">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()} variant="outline">
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
62
src/components/layout/footer.tsx
Normal file
62
src/components/layout/footer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-studio-medium bg-studio-black">
|
||||
<div className="container py-12">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Phoenix Sankofa Cloud</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
The sovereign cloud born of fire and ancestral wisdom.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold">Product</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li>
|
||||
<Link href="/products" className="hover:text-phoenix-fire">
|
||||
Products
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/about" className="hover:text-phoenix-fire">
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold">Resources</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li>
|
||||
<Link href="/manifesto" className="hover:text-phoenix-fire">
|
||||
Manifesto
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/docs" className="hover:text-phoenix-fire">
|
||||
Documentation
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold">Company</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-400">
|
||||
<li>
|
||||
<Link href="/about" className="hover:text-phoenix-fire">
|
||||
About Us
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-studio-medium pt-8 text-center text-sm text-gray-400">
|
||||
<p>© {new Date().getFullYear()} Phoenix Sankofa Cloud. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
36
src/components/layout/header.tsx
Normal file
36
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-studio-medium bg-studio-black/95 backdrop-blur supports-[backdrop-filter]:bg-studio-black/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-phoenix-fire to-sankofa-gold bg-clip-text text-transparent">
|
||||
Phoenix Sankofa Cloud
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex flex-1 items-center space-x-6 text-sm font-medium">
|
||||
<Link href="/products" className="transition-colors hover:text-phoenix-fire">
|
||||
Products
|
||||
</Link>
|
||||
<Link href="/about" className="transition-colors hover:text-phoenix-fire">
|
||||
About
|
||||
</Link>
|
||||
<Link href="/manifesto" className="transition-colors hover:text-phoenix-fire">
|
||||
Manifesto
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Sign In
|
||||
</Button>
|
||||
<Button variant="phoenix" size="sm">
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
42
src/components/layout/navbar.tsx
Normal file
42
src/components/layout/navbar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Home', href: '/' },
|
||||
{ title: 'Products', href: '/products' },
|
||||
{ title: 'About', href: '/about' },
|
||||
{ title: 'Manifesto', href: '/manifesto' },
|
||||
]
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className="flex space-x-6">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'text-sm font-medium transition-colors hover:text-phoenix-fire',
|
||||
isActive ? 'text-phoenix-fire' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
49
src/components/layout/sidebar.tsx
Normal file
49
src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface SidebarItem {
|
||||
title: string
|
||||
href: string
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
items: SidebarItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Sidebar({ items, className }: SidebarProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className={cn('w-64 border-r border-studio-medium bg-studio-black p-4', className)}>
|
||||
<nav className="space-y-1">
|
||||
{items.map((item) => {
|
||||
const isActive = pathname === item.href
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center space-x-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-studio-medium text-phoenix-fire'
|
||||
: 'text-gray-400 hover:bg-studio-medium hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
58
src/components/ui/button.test.tsx
Normal file
58
src/components/ui/button.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@/lib/test-utils'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Button } from './button'
|
||||
|
||||
describe('Button Component', () => {
|
||||
it('should render button with text', () => {
|
||||
render(<Button>Click me</Button>)
|
||||
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle click events', async () => {
|
||||
const handleClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Button onClick={handleClick}>Click me</Button>)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply variant classes', () => {
|
||||
const { rerender } = render(<Button variant="phoenix">Phoenix</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-phoenix-fire')
|
||||
|
||||
rerender(<Button variant="sankofa">Sankofa</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('bg-sankofa-gold')
|
||||
|
||||
rerender(<Button variant="outline">Outline</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('border')
|
||||
})
|
||||
|
||||
it('should apply size classes', () => {
|
||||
const { rerender } = render(<Button size="sm">Small</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('h-8')
|
||||
|
||||
rerender(<Button size="lg">Large</Button>)
|
||||
expect(screen.getByRole('button')).toHaveClass('h-12')
|
||||
})
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
render(<Button disabled>Disabled</Button>)
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not call onClick when disabled', async () => {
|
||||
const handleClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Button disabled onClick={handleClick}>
|
||||
Disabled
|
||||
</Button>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
40
src/components/ui/button.tsx
Normal file
40
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'phoenix' | 'sankofa' | 'outline' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', size = 'md', ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
|
||||
|
||||
const variants = {
|
||||
default: 'bg-studio-medium text-foreground hover:bg-studio-dark',
|
||||
phoenix: 'bg-phoenix-fire text-white hover:bg-phoenix-flame glow-phoenix',
|
||||
sankofa: 'bg-sankofa-gold text-studio-black hover:bg-yellow-400 glow-sankofa',
|
||||
outline: 'border border-studio-medium bg-transparent hover:bg-studio-medium',
|
||||
ghost: 'hover:bg-studio-medium',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-base',
|
||||
lg: 'h-12 px-6 text-lg',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button }
|
||||
|
||||
50
src/components/ui/card.test.tsx
Normal file
50
src/components/ui/card.test.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@/lib/test-utils'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card'
|
||||
|
||||
describe('Card Components', () => {
|
||||
it('should render Card with children', () => {
|
||||
render(
|
||||
<Card>
|
||||
<p>Card content</p>
|
||||
</Card>
|
||||
)
|
||||
expect(screen.getByText('Card content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CardHeader with title and description', () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card Description</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Card Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CardContent', () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p>Content here</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
expect(screen.getByText('Content here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CardFooter', () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardFooter>
|
||||
<button>Action</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /action/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
76
src/components/ui/card.tsx
Normal file
76
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border border-studio-medium bg-studio-dark p-6 shadow-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('pt-6', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
102
src/components/ui/dialog.tsx
Normal file
102
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-studio-medium bg-studio-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-studio-black transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-phoenix-fire focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-studio-medium data-[state=open]:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-gray-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
||||
183
src/components/ui/dropdown-menu.tsx
Normal file
183
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-studio-medium data-[state=open]:bg-studio-medium',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-studio-medium bg-studio-dark p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-studio-medium bg-studio-dark p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-studio-medium focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-studio-medium focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-studio-medium focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-studio-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
|
||||
157
src/components/ui/form.tsx
Normal file
157
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from 'react-hook-form'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from './label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
FormItem.displayName = 'FormItem'
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-status-error', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = 'FormLabel'
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = 'FormControl'
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-gray-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = 'FormDescription'
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium text-status-error', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
)
|
||||
FormMessage.displayName = 'FormMessage'
|
||||
|
||||
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField }
|
||||
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-studio-medium bg-studio-dark px-3 py-2 text-sm text-foreground ring-offset-studio-black file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-phoenix-fire focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
aria-label={props['aria-label'] || (props.placeholder ? `Input: ${props.placeholder}` : 'Input field')}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
|
||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
||||
29
src/components/ui/loading.tsx
Normal file
29
src/components/ui/loading.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LoadingProps {
|
||||
className?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function Loading({ className, size = 'md' }: LoadingProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-2 border-studio-medium border-t-phoenix-fire',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
151
src/components/ui/select.tsx
Normal file
151
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-studio-medium bg-studio-dark px-3 py-2 text-sm text-foreground ring-offset-studio-black placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-phoenix-fire focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-studio-medium bg-studio-dark text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-studio-medium focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-studio-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Skeleton({ className, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-md bg-studio-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
87
src/components/ui/table.tsx
Normal file
87
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn('border-t border-studio-medium bg-studio-medium font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-studio-medium transition-colors hover:bg-studio-medium/50 data-[state=selected]:bg-studio-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-gray-400', className)} {...props} />
|
||||
))
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
|
||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-studio-medium p-1 text-gray-400',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-studio-black transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-phoenix-fire focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-studio-dark data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-studio-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-phoenix-fire focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
||||
121
src/components/ui/toast.tsx
Normal file
121
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from 'react'
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-studio-medium bg-studio-dark p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-studio-medium bg-studio-dark text-foreground',
|
||||
success: 'border-status-success bg-status-success/10 text-status-success',
|
||||
error: 'border-status-error bg-status-error/10 text-status-error',
|
||||
warning: 'border-status-warning bg-status-warning/10 text-status-warning',
|
||||
info: 'border-status-info bg-status-info/10 text-status-info',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-studio-medium bg-studio-dark px-3 text-sm font-medium ring-offset-studio-black transition-colors hover:bg-studio-medium focus:outline-none focus:ring-2 focus:ring-phoenix-fire focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-status-error group-[.destructive]:hover:border-status-error group-[.destructive]:hover:bg-status-error group-[.destructive]:hover:text-foreground group-[.destructive]:focus:ring-status-error',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-status-error group-[.destructive]:hover:text-status-error group-[.destructive]:focus:ring-status-error',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
|
||||
34
src/components/ui/toaster.tsx
Normal file
34
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from './toast'
|
||||
import { useToast } from './use-toast'
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
186
src/components/ui/use-toast.ts
Normal file
186
src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import * as React from 'react'
|
||||
import type { ToastActionElement, ToastProps } from './toast'
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST']
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST']
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
|
||||
41
src/components/well-architected/LensSelector.tsx
Normal file
41
src/components/well-architected/LensSelector.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { pillars, getPillarColor } from '@/lib/design-system'
|
||||
|
||||
type PillarCode = keyof typeof pillars
|
||||
|
||||
interface LensSelectorProps {
|
||||
selectedLens: PillarCode | null
|
||||
onLensChange: (lens: PillarCode | null) => void
|
||||
}
|
||||
|
||||
export default function LensSelector({ selectedLens, onLensChange }: LensSelectorProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={selectedLens === null ? 'phoenix' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onLensChange(null)}
|
||||
>
|
||||
All Pillars
|
||||
</Button>
|
||||
{Object.entries(pillars).map(([code, pillar]) => (
|
||||
<Button
|
||||
key={code}
|
||||
variant={selectedLens === code ? 'phoenix' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onLensChange(code as PillarCode)}
|
||||
style={
|
||||
selectedLens === code
|
||||
? { backgroundColor: getPillarColor(code as PillarCode) }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{pillar.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
125
src/components/well-architected/WAFDashboard.tsx
Normal file
125
src/components/well-architected/WAFDashboard.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import LensSelector from './LensSelector'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { getHealthScoreColor, getPillarColor, pillars } from '@/lib/design-system'
|
||||
import type { PillarCode } from './LensSelector'
|
||||
|
||||
interface Finding {
|
||||
id: string
|
||||
control: string
|
||||
resource: string
|
||||
status: 'PASS' | 'FAIL' | 'WARNING'
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
title: string
|
||||
}
|
||||
|
||||
const mockFindings: Finding[] = [
|
||||
{
|
||||
id: '1',
|
||||
control: 'SECURITY-001',
|
||||
resource: 'Region 1',
|
||||
status: 'PASS',
|
||||
severity: 'LOW',
|
||||
title: 'Encryption at rest enabled',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
control: 'RELIABILITY-002',
|
||||
resource: 'Cluster 1',
|
||||
status: 'WARNING',
|
||||
severity: 'MEDIUM',
|
||||
title: 'Backup frequency below recommended',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
control: 'COST-003',
|
||||
resource: 'Service 1',
|
||||
status: 'FAIL',
|
||||
severity: 'HIGH',
|
||||
title: 'Resource over-provisioned',
|
||||
},
|
||||
]
|
||||
|
||||
export default function WAFDashboard() {
|
||||
const [selectedLens, setSelectedLens] = useState<PillarCode | null>(null)
|
||||
|
||||
const filteredFindings = selectedLens
|
||||
? mockFindings.filter((f) => f.control.startsWith(selectedLens))
|
||||
: mockFindings
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-white">Well-Architected Framework</h1>
|
||||
<LensSelector selectedLens={selectedLens} onLensChange={setSelectedLens} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3 lg:grid-cols-6">
|
||||
{Object.entries(pillars).map(([code, pillar]) => {
|
||||
const score = Math.floor(Math.random() * 30) + 70 // Mock score 70-100
|
||||
const color = getHealthScoreColor(score)
|
||||
|
||||
return (
|
||||
<Card key={code}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm" style={{ color: getPillarColor(code as PillarCode) }}>
|
||||
{pillar.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold" style={{ color }}>
|
||||
{score}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Findings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{filteredFindings.map((finding) => (
|
||||
<div
|
||||
key={finding.id}
|
||||
className="rounded-lg border border-studio-medium bg-studio-dark p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{finding.title}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{finding.control} • {finding.resource}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="rounded px-2 py-1 text-xs font-semibold"
|
||||
style={{
|
||||
backgroundColor:
|
||||
finding.status === 'PASS'
|
||||
? '#00FF88'
|
||||
: finding.status === 'WARNING'
|
||||
? '#FFB800'
|
||||
: '#FF0040',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
{finding.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{finding.severity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
17
src/content/brand.md
Normal file
17
src/content/brand.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Phoenix Sankofa Cloud Brand Content
|
||||
|
||||
## Primary Tagline
|
||||
The sovereign cloud born of fire and ancestral wisdom.
|
||||
|
||||
## Mission Statement
|
||||
Phoenix Sankofa Cloud exists to build the world's first sovereign AI cloud infrastructure that honors ancestral wisdom, reflects cultural identity, serves global sovereignty, and transforms through the power of rebirth and return.
|
||||
|
||||
## Core Values
|
||||
- Remember: Where we came from
|
||||
- Retrieve: What was essential
|
||||
- Restore: Identity and sovereignty
|
||||
- Rise: Forward with purpose
|
||||
|
||||
## Brand Promise
|
||||
We promise infrastructure that remembers its origins, retrieves ancestral wisdom, restores identity and sovereignty, and rises forward with purpose.
|
||||
|
||||
93
src/lib/accessibility.ts
Normal file
93
src/lib/accessibility.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Accessibility utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get accessible label for an element
|
||||
*/
|
||||
export function getAccessibleLabel(
|
||||
label?: string,
|
||||
placeholder?: string,
|
||||
fallback = 'Interactive element'
|
||||
): string {
|
||||
return label || placeholder || fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID for form elements
|
||||
*/
|
||||
export function generateId(prefix = 'id'): string {
|
||||
return `${prefix}-${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element is focusable
|
||||
*/
|
||||
export function isFocusable(element: HTMLElement): boolean {
|
||||
const focusableSelectors = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(', ')
|
||||
|
||||
return element.matches(focusableSelectors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap focus within a container
|
||||
*/
|
||||
export function trapFocus(container: HTMLElement): () => void {
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', handleTab)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
container.removeEventListener('keydown', handleTab)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce message to screen readers
|
||||
*/
|
||||
export function announceToScreenReader(message: string, priority: 'polite' | 'assertive' = 'polite') {
|
||||
const announcement = document.createElement('div')
|
||||
announcement.setAttribute('role', 'status')
|
||||
announcement.setAttribute('aria-live', priority)
|
||||
announcement.setAttribute('aria-atomic', 'true')
|
||||
announcement.className = 'sr-only'
|
||||
announcement.textContent = message
|
||||
|
||||
document.body.appendChild(announcement)
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(announcement)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
53
src/lib/auth-storage.ts
Normal file
53
src/lib/auth-storage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Secure authentication token storage
|
||||
*
|
||||
* This module provides an abstraction for storing authentication tokens
|
||||
* securely. In production, tokens should be stored in httpOnly cookies,
|
||||
* but for development we can use a more permissive approach.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Store authentication token
|
||||
*/
|
||||
export function setAuthToken(token: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// In production, this should set an httpOnly cookie via API
|
||||
// For now, we'll use sessionStorage as a compromise (better than localStorage)
|
||||
// TODO: Implement httpOnly cookie storage via API endpoint
|
||||
sessionStorage.setItem('auth_token', token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication token
|
||||
*/
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
// TODO: Get from httpOnly cookie via API endpoint
|
||||
return sessionStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove authentication token
|
||||
*/
|
||||
export function removeAuthToken(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
sessionStorage.removeItem('auth_token')
|
||||
// TODO: Clear httpOnly cookie via API endpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return getAuthToken() !== null
|
||||
}
|
||||
|
||||
111
src/lib/content.ts
Normal file
111
src/lib/content.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Content Management Utilities
|
||||
*
|
||||
* Functions for loading and managing brand content
|
||||
*/
|
||||
|
||||
export interface BrandContent {
|
||||
tagline: string
|
||||
mission: string
|
||||
values: string[]
|
||||
promise: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Load brand content
|
||||
* In production, this could load from CMS, API, or database
|
||||
*/
|
||||
export async function loadBrandContent(): Promise<BrandContent> {
|
||||
// For now, return static content
|
||||
// In production, this would fetch from API, CMS, or database
|
||||
return {
|
||||
tagline: 'The sovereign cloud born of fire and ancestral wisdom.',
|
||||
mission: 'Phoenix Sankofa Cloud exists to build the world\'s first sovereign AI cloud infrastructure that honors ancestral wisdom, reflects cultural identity, serves global sovereignty, and transforms through the power of rebirth and return.',
|
||||
values: [
|
||||
'Remember: Where we came from',
|
||||
'Retrieve: What was essential',
|
||||
'Restore: Identity and sovereignty',
|
||||
'Rise: Forward with purpose',
|
||||
],
|
||||
promise: 'We promise infrastructure that remembers its origins, retrieves ancestral wisdom, restores identity and sovereignty, and rises forward with purpose.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product name by category
|
||||
*/
|
||||
export function getProductName(category: string, index: number): string {
|
||||
const productNames: Record<string, string[]> = {
|
||||
compute: [
|
||||
'PhoenixCore Compute',
|
||||
'SankofaEdge Nodes',
|
||||
'AkanFire VM Engine',
|
||||
],
|
||||
storage: [
|
||||
'OkraVault Storage',
|
||||
'Nananom Archive',
|
||||
'Egg of the Phoenix Object Store',
|
||||
],
|
||||
networking: [
|
||||
'SankofaGrid Global Mesh',
|
||||
'AkanSphere Edge Routing',
|
||||
'PhoenixFlight Network Fabric',
|
||||
],
|
||||
ai: [
|
||||
'Firebird AI Engine',
|
||||
'Sankofa Memory Model',
|
||||
'Ancestral Neural Fabric',
|
||||
],
|
||||
security: [
|
||||
'Aegis of Akan Shield',
|
||||
'PhoenixGuard IAM',
|
||||
'Nsamankom Sentinel',
|
||||
],
|
||||
identity: [
|
||||
'OkraID',
|
||||
'AkanAuth Sovereign Identity Plane',
|
||||
],
|
||||
}
|
||||
|
||||
return productNames[category.toLowerCase()]?.[index] || 'Unknown Product'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product description
|
||||
*/
|
||||
export function getProductDescription(category: string, index: number): string {
|
||||
const descriptions: Record<string, string[]> = {
|
||||
compute: [
|
||||
'Core compute engine powered by Phoenix fire',
|
||||
'Edge nodes that remember and return data',
|
||||
'High-performance VMs with Akan heritage',
|
||||
],
|
||||
storage: [
|
||||
'Storage for the soul of your data',
|
||||
'Long-term archival with ancestral memory',
|
||||
'Object storage for transformation',
|
||||
],
|
||||
networking: [
|
||||
'Global network mesh that remembers',
|
||||
'Edge routing with cultural intelligence',
|
||||
'High-performance network fabric',
|
||||
],
|
||||
ai: [
|
||||
'AI that transforms like fire',
|
||||
'AI models that remember and learn',
|
||||
'Distributed AI with ancestral patterns',
|
||||
],
|
||||
security: [
|
||||
'Comprehensive security platform',
|
||||
'Identity and access management',
|
||||
'Security monitoring with ancestral protection',
|
||||
],
|
||||
identity: [
|
||||
'Soul-powered identity framework',
|
||||
'Sovereign authentication platform',
|
||||
],
|
||||
}
|
||||
|
||||
return descriptions[category.toLowerCase()]?.[index] || 'Product description'
|
||||
}
|
||||
|
||||
43
src/lib/design-system.test.ts
Normal file
43
src/lib/design-system.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getHealthScoreColor, getPillarColor, pillars } from './design-system'
|
||||
|
||||
describe('Design System Utilities', () => {
|
||||
describe('getHealthScoreColor', () => {
|
||||
it('should return excellent color for scores >= 90', () => {
|
||||
expect(getHealthScoreColor(90)).toBe('#00FF88')
|
||||
expect(getHealthScoreColor(100)).toBe('#00FF88')
|
||||
})
|
||||
|
||||
it('should return good color for scores >= 70', () => {
|
||||
expect(getHealthScoreColor(70)).toBe('#FFB800')
|
||||
expect(getHealthScoreColor(89)).toBe('#FFB800')
|
||||
})
|
||||
|
||||
it('should return fair color for scores >= 50', () => {
|
||||
expect(getHealthScoreColor(50)).toBe('#FF8C00')
|
||||
expect(getHealthScoreColor(69)).toBe('#FF8C00')
|
||||
})
|
||||
|
||||
it('should return poor color for scores < 50', () => {
|
||||
expect(getHealthScoreColor(49)).toBe('#FF0040')
|
||||
expect(getHealthScoreColor(0)).toBe('#FF0040')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPillarColor', () => {
|
||||
it('should return correct color for each pillar', () => {
|
||||
expect(getPillarColor('SECURITY')).toBe('#FF0040')
|
||||
expect(getPillarColor('RELIABILITY')).toBe('#00FF88')
|
||||
expect(getPillarColor('COST_OPTIMIZATION')).toBe('#00FFFF')
|
||||
expect(getPillarColor('PERFORMANCE_EFFICIENCY')).toBe('#00FFD1')
|
||||
expect(getPillarColor('OPERATIONAL_EXCELLENCE')).toBe('#FF00FF')
|
||||
expect(getPillarColor('SUSTAINABILITY')).toBe('#00FF88')
|
||||
})
|
||||
|
||||
it('should return default color for invalid pillar', () => {
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(getPillarColor('INVALID')).toBe('#00FFFF')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
150
src/lib/design-system.ts
Normal file
150
src/lib/design-system.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Phoenix Sankofa Cloud Design System
|
||||
*
|
||||
* Design tokens and utilities for the brand
|
||||
*/
|
||||
|
||||
// Color Palette
|
||||
export const colors = {
|
||||
phoenix: {
|
||||
fire: '#FF4500',
|
||||
flame: '#FF8C00',
|
||||
ember: '#FF6B35',
|
||||
},
|
||||
sankofa: {
|
||||
gold: '#FFD700',
|
||||
earth: '#8B4513',
|
||||
},
|
||||
sovereignty: {
|
||||
purple: '#6A0DAD',
|
||||
deep: '#4B0082',
|
||||
},
|
||||
ancestral: {
|
||||
blue: '#1E3A8A',
|
||||
deep: '#0F1B3D',
|
||||
},
|
||||
studio: {
|
||||
black: '#0A0A0A',
|
||||
dark: '#1A1A1A',
|
||||
medium: '#2A2A2A',
|
||||
},
|
||||
neon: {
|
||||
teal: '#00FFD1',
|
||||
magenta: '#FF00FF',
|
||||
cyan: '#00FFFF',
|
||||
amber: '#FFB800',
|
||||
},
|
||||
status: {
|
||||
success: '#00FF88',
|
||||
warning: '#FFB800',
|
||||
error: '#FF0040',
|
||||
info: '#00B8FF',
|
||||
},
|
||||
} as const
|
||||
|
||||
// Typography Scale
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: 'var(--font-inter), system-ui, sans-serif',
|
||||
mono: 'var(--font-mono), monospace',
|
||||
},
|
||||
fontSize: {
|
||||
xs: '0.75rem', // 12px
|
||||
sm: '0.875rem', // 14px
|
||||
base: '1rem', // 16px
|
||||
lg: '1.125rem', // 18px
|
||||
xl: '1.25rem', // 20px
|
||||
'2xl': '1.5rem', // 24px
|
||||
'3xl': '2rem', // 32px
|
||||
'4xl': '3rem', // 48px
|
||||
'5xl': '4rem', // 64px
|
||||
},
|
||||
fontWeight: {
|
||||
light: 300,
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
},
|
||||
} as const
|
||||
|
||||
// Spacing Scale (4px base unit)
|
||||
export const spacing = {
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
xl: '32px',
|
||||
'2xl': '48px',
|
||||
'3xl': '64px',
|
||||
'4xl': '96px',
|
||||
'5xl': '128px',
|
||||
} as const
|
||||
|
||||
// Animation Durations
|
||||
export const animation = {
|
||||
fast: '150ms',
|
||||
normal: '200ms',
|
||||
slow: '300ms',
|
||||
slower: '500ms',
|
||||
} as const
|
||||
|
||||
// Well-Architected Framework Pillars
|
||||
export const pillars = {
|
||||
SECURITY: {
|
||||
code: 'SECURITY',
|
||||
name: 'Security',
|
||||
color: colors.status.error,
|
||||
},
|
||||
RELIABILITY: {
|
||||
code: 'RELIABILITY',
|
||||
name: 'Reliability',
|
||||
color: colors.status.success,
|
||||
},
|
||||
COST_OPTIMIZATION: {
|
||||
code: 'COST_OPTIMIZATION',
|
||||
name: 'Cost Optimization',
|
||||
color: colors.neon.cyan,
|
||||
},
|
||||
PERFORMANCE_EFFICIENCY: {
|
||||
code: 'PERFORMANCE_EFFICIENCY',
|
||||
name: 'Performance Efficiency',
|
||||
color: colors.neon.teal,
|
||||
},
|
||||
OPERATIONAL_EXCELLENCE: {
|
||||
code: 'OPERATIONAL_EXCELLENCE',
|
||||
name: 'Operational Excellence',
|
||||
color: colors.neon.magenta,
|
||||
},
|
||||
SUSTAINABILITY: {
|
||||
code: 'SUSTAINABILITY',
|
||||
name: 'Sustainability',
|
||||
color: colors.status.success,
|
||||
},
|
||||
} as const
|
||||
|
||||
// Health Score Colors
|
||||
export const healthScoreColors = {
|
||||
excellent: colors.status.success, // 90-100
|
||||
good: colors.neon.amber, // 70-89
|
||||
fair: colors.phoenix.flame, // 50-69
|
||||
poor: colors.status.error, // 0-49
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Get health score color based on score (0-100)
|
||||
*/
|
||||
export function getHealthScoreColor(score: number): string {
|
||||
if (score >= 90) return healthScoreColors.excellent
|
||||
if (score >= 70) return healthScoreColors.good
|
||||
if (score >= 50) return healthScoreColors.fair
|
||||
return healthScoreColors.poor
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pillar color
|
||||
*/
|
||||
export function getPillarColor(pillarCode: keyof typeof pillars): string {
|
||||
return pillars[pillarCode]?.color || colors.neon.cyan
|
||||
}
|
||||
|
||||
59
src/lib/env.ts
Normal file
59
src/lib/env.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Environment variable validation and access
|
||||
*/
|
||||
|
||||
const requiredEnvVars = {
|
||||
// Add required env vars here as they are needed
|
||||
// NEXT_PUBLIC_GRAPHQL_ENDPOINT: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT,
|
||||
} as const
|
||||
|
||||
const optionalEnvVars = {
|
||||
NEXT_PUBLIC_GRAPHQL_ENDPOINT: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || '/api/graphql',
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
*/
|
||||
export function validateEnv() {
|
||||
const missing: string[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(requiredEnvVars)) {
|
||||
if (!value) {
|
||||
missing.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables: ${missing.join(', ')}\n` +
|
||||
'Please check your .env file or environment configuration.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variable value
|
||||
*/
|
||||
export function getEnv(key: keyof typeof optionalEnvVars): string | undefined {
|
||||
return optionalEnvVars[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all environment variables (for debugging)
|
||||
*/
|
||||
export function getAllEnv() {
|
||||
return {
|
||||
...requiredEnvVars,
|
||||
...optionalEnvVars,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate on module load in production
|
||||
if (typeof window === 'undefined' && process.env.NODE_ENV === 'production') {
|
||||
validateEnv()
|
||||
}
|
||||
|
||||
92
src/lib/error-handler.ts
Normal file
92
src/lib/error-handler.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Centralized error handling utilities
|
||||
*/
|
||||
|
||||
import {
|
||||
AppError,
|
||||
NetworkError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
NotFoundError,
|
||||
ServerError,
|
||||
} from './types/errors'
|
||||
|
||||
/**
|
||||
* Convert an unknown error to an AppError
|
||||
*/
|
||||
export function normalizeError(error: unknown): AppError {
|
||||
if (error instanceof AppError) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
// Check for network errors
|
||||
if (error.message.includes('fetch') || error.message.includes('network')) {
|
||||
return new NetworkError(error.message, error)
|
||||
}
|
||||
|
||||
return new ServerError(error.message, error)
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return new ServerError(error)
|
||||
}
|
||||
|
||||
return new ServerError('An unknown error occurred')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors
|
||||
*/
|
||||
export function handleApiError(error: unknown): AppError {
|
||||
const normalized = normalizeError(error)
|
||||
|
||||
// Log error for monitoring
|
||||
if (typeof window !== 'undefined') {
|
||||
console.error('API Error:', normalized)
|
||||
// TODO: Send to error tracking service
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyMessage(error: AppError): string {
|
||||
switch (error.code) {
|
||||
case 'NETWORK_ERROR':
|
||||
return 'Unable to connect to the server. Please check your internet connection and try again.'
|
||||
case 'VALIDATION_ERROR':
|
||||
return error.message || 'Please check your input and try again.'
|
||||
case 'AUTH_ERROR':
|
||||
return 'Your session has expired. Please log in again.'
|
||||
case 'AUTHZ_ERROR':
|
||||
return 'You do not have permission to perform this action.'
|
||||
case 'NOT_FOUND':
|
||||
return 'The requested resource was not found.'
|
||||
case 'SERVER_ERROR':
|
||||
return 'An error occurred on the server. Please try again later.'
|
||||
default:
|
||||
return error.message || 'An unexpected error occurred. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*/
|
||||
export function isRetryableError(error: AppError): boolean {
|
||||
return (
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
(error.statusCode !== undefined && error.statusCode >= 500 && error.statusCode < 600)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retry delay in milliseconds
|
||||
*/
|
||||
export function getRetryDelay(attempt: number, baseDelay = 1000): number {
|
||||
return Math.min(baseDelay * Math.pow(2, attempt), 30000) // Max 30 seconds
|
||||
}
|
||||
|
||||
58
src/lib/graphql/client.ts
Normal file
58
src/lib/graphql/client.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ApolloClient, InMemoryCache, createHttpLink, from, ApolloLink } from '@apollo/client'
|
||||
import { setContext } from '@apollo/client/link/context'
|
||||
import { onError } from '@apollo/client/link/error'
|
||||
import { handleApiError, getUserFriendlyMessage } from '@/lib/error-handler'
|
||||
|
||||
// HTTP Link - configured to use the GraphQL API
|
||||
const httpLink = createHttpLink({
|
||||
uri: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql',
|
||||
})
|
||||
|
||||
// Auth Link - for adding authentication headers
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
// Get the authentication token from secure storage
|
||||
let token: string | null = null
|
||||
if (typeof window !== 'undefined') {
|
||||
// Try to get from secure storage
|
||||
token = sessionStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Error Link - handle GraphQL and network errors
|
||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||
if (graphQLErrors) {
|
||||
graphQLErrors.forEach(({ message, locations, path }) => {
|
||||
const error = handleApiError(new Error(message))
|
||||
console.error(
|
||||
`[GraphQL error]: Message: ${getUserFriendlyMessage(error)}, Location: ${locations}, Path: ${path}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (networkError) {
|
||||
const error = handleApiError(networkError)
|
||||
console.error(`[Network error]: ${getUserFriendlyMessage(error)}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Create Apollo Client
|
||||
export const apolloClient = new ApolloClient({
|
||||
link: from([errorLink, authLink, httpLink]),
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
query: {
|
||||
errorPolicy: 'all',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
4
src/lib/graphql/hooks/index.ts
Normal file
4
src/lib/graphql/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './useAuth'
|
||||
export * from './useResources'
|
||||
export * from './useSites'
|
||||
|
||||
46
src/lib/graphql/hooks/useAuth.ts
Normal file
46
src/lib/graphql/hooks/useAuth.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { LOGIN, LOGOUT } from '../mutations'
|
||||
import { GET_ME } from '../queries'
|
||||
import { setAuthToken, removeAuthToken } from '@/lib/auth-storage'
|
||||
|
||||
export function useLogin() {
|
||||
const [mutate, { loading, error }] = useMutation(LOGIN, {
|
||||
onCompleted: (data) => {
|
||||
if (data?.login?.token) {
|
||||
setAuthToken(data.login.token)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
login: (email: string, password: string) =>
|
||||
mutate({ variables: { email, password } }),
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const [mutate, { loading, error }] = useMutation(LOGOUT, {
|
||||
onCompleted: () => {
|
||||
removeAuthToken()
|
||||
// Optionally redirect to login page
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
logout: () => mutate(),
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
return useQuery(GET_ME, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
})
|
||||
}
|
||||
|
||||
53
src/lib/graphql/hooks/useResources.ts
Normal file
53
src/lib/graphql/hooks/useResources.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useQuery, useMutation, useSubscription } from '@apollo/client'
|
||||
import { GET_RESOURCES, GET_RESOURCE, CREATE_RESOURCE, UPDATE_RESOURCE, DELETE_RESOURCE } from '../queries'
|
||||
import { CREATE_RESOURCE as CREATE_RESOURCE_MUTATION, UPDATE_RESOURCE as UPDATE_RESOURCE_MUTATION, DELETE_RESOURCE as DELETE_RESOURCE_MUTATION } from '../mutations'
|
||||
|
||||
export function useResources(filter?: { type?: string; status?: string; siteId?: string }) {
|
||||
return useQuery(GET_RESOURCES, {
|
||||
variables: { filter },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
})
|
||||
}
|
||||
|
||||
export function useResource(id: string) {
|
||||
return useQuery(GET_RESOURCE, {
|
||||
variables: { id },
|
||||
skip: !id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateResource() {
|
||||
const [mutate, { loading, error }] = useMutation(CREATE_RESOURCE_MUTATION, {
|
||||
refetchQueries: [{ query: GET_RESOURCES }],
|
||||
})
|
||||
|
||||
return {
|
||||
createResource: mutate,
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdateResource() {
|
||||
const [mutate, { loading, error }] = useMutation(UPDATE_RESOURCE_MUTATION, {
|
||||
refetchQueries: [{ query: GET_RESOURCES }],
|
||||
})
|
||||
|
||||
return {
|
||||
updateResource: mutate,
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeleteResource() {
|
||||
const [mutate, { loading, error }] = useMutation(DELETE_RESOURCE_MUTATION, {
|
||||
refetchQueries: [{ query: GET_RESOURCES }],
|
||||
})
|
||||
|
||||
return {
|
||||
deleteResource: mutate,
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
16
src/lib/graphql/hooks/useSites.ts
Normal file
16
src/lib/graphql/hooks/useSites.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { GET_SITES, GET_SITE } from '../queries'
|
||||
|
||||
export function useSites() {
|
||||
return useQuery(GET_SITES, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
})
|
||||
}
|
||||
|
||||
export function useSite(id: string) {
|
||||
return useQuery(GET_SITE, {
|
||||
variables: { id },
|
||||
skip: !id,
|
||||
})
|
||||
}
|
||||
|
||||
89
src/lib/graphql/mutations.ts
Normal file
89
src/lib/graphql/mutations.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { gql } from '@apollo/client'
|
||||
|
||||
export const LOGIN = gql`
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
user {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const LOGOUT = gql`
|
||||
mutation Logout {
|
||||
logout
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_RESOURCE = gql`
|
||||
mutation CreateResource($input: CreateResourceInput!) {
|
||||
createResource(input: $input) {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
site {
|
||||
id
|
||||
name
|
||||
}
|
||||
metadata
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_RESOURCE = gql`
|
||||
mutation UpdateResource($id: ID!, $input: UpdateResourceInput!) {
|
||||
updateResource(id: $id, input: $input) {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
metadata
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_RESOURCE = gql`
|
||||
mutation DeleteResource($id: ID!) {
|
||||
deleteResource(id: $id)
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_USER = gql`
|
||||
mutation CreateUser($input: CreateUserInput!) {
|
||||
createUser(input: $input) {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_USER = gql`
|
||||
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
|
||||
updateUser(id: $id, input: $input) {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_USER = gql`
|
||||
mutation DeleteUser($id: ID!) {
|
||||
deleteUser(id: $id)
|
||||
}
|
||||
`
|
||||
|
||||
113
src/lib/graphql/queries.ts
Normal file
113
src/lib/graphql/queries.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { gql } from '@apollo/client'
|
||||
|
||||
export const GET_RESOURCES = gql`
|
||||
query GetResources($filter: ResourceFilter) {
|
||||
resources(filter: $filter) {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
site {
|
||||
id
|
||||
name
|
||||
region
|
||||
}
|
||||
metadata
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_RESOURCE = gql`
|
||||
query GetResource($id: ID!) {
|
||||
resource(id: $id) {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
site {
|
||||
id
|
||||
name
|
||||
region
|
||||
}
|
||||
metadata
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_SITES = gql`
|
||||
query GetSites {
|
||||
sites {
|
||||
id
|
||||
name
|
||||
region
|
||||
status
|
||||
metadata
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_SITE = gql`
|
||||
query GetSite($id: ID!) {
|
||||
site(id: $id) {
|
||||
id
|
||||
name
|
||||
region
|
||||
status
|
||||
resources {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
}
|
||||
metadata
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_ME = gql`
|
||||
query GetMe {
|
||||
me {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_USERS = gql`
|
||||
query GetUsers {
|
||||
users {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_USER = gql`
|
||||
query GetUser($id: ID!) {
|
||||
user(id: $id) {
|
||||
id
|
||||
email
|
||||
name
|
||||
role
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
31
src/lib/graphql/queries/metrics.ts
Normal file
31
src/lib/graphql/queries/metrics.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { gql } from '@apollo/client'
|
||||
|
||||
// Query for getting metrics
|
||||
export const GET_METRICS = gql`
|
||||
query GetMetrics($resourceId: ID!, $metricType: MetricType!, $timeRange: TimeRange!) {
|
||||
metrics(resourceId: $resourceId, metricType: $metricType, timeRange: $timeRange) {
|
||||
resource {
|
||||
id
|
||||
name
|
||||
}
|
||||
metricType
|
||||
values {
|
||||
timestamp
|
||||
value
|
||||
labels
|
||||
}
|
||||
timeRange {
|
||||
start
|
||||
end
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Query for getting health status
|
||||
export const GET_HEALTH = gql`
|
||||
query GetHealth($resourceId: ID!) {
|
||||
health(resourceId: $resourceId)
|
||||
}
|
||||
`
|
||||
|
||||
104
src/lib/graphql/queries/resources.ts
Normal file
104
src/lib/graphql/queries/resources.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { gql } from '@apollo/client'
|
||||
|
||||
// Query for getting all regions
|
||||
export const GET_REGIONS = gql`
|
||||
query GetRegions {
|
||||
regions {
|
||||
id
|
||||
name
|
||||
code
|
||||
country
|
||||
coordinates {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
sites {
|
||||
id
|
||||
name
|
||||
}
|
||||
clusters {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Query for getting a specific region
|
||||
export const GET_REGION = gql`
|
||||
query GetRegion($id: ID!) {
|
||||
region(id: $id) {
|
||||
id
|
||||
name
|
||||
code
|
||||
country
|
||||
coordinates {
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
sites {
|
||||
id
|
||||
name
|
||||
clusters {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
clusters {
|
||||
id
|
||||
name
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
health
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Query for getting resources
|
||||
export const GET_RESOURCES = gql`
|
||||
query GetResources($filter: ResourceFilter) {
|
||||
resources(filter: $filter) {
|
||||
id
|
||||
name
|
||||
type
|
||||
region {
|
||||
id
|
||||
name
|
||||
}
|
||||
health
|
||||
metadata
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Query for getting network topology
|
||||
export const GET_NETWORK_TOPOLOGY = gql`
|
||||
query GetNetworkTopology($regionId: ID) {
|
||||
networkTopology(regionId: $regionId) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
type
|
||||
position {
|
||||
x
|
||||
y
|
||||
z
|
||||
}
|
||||
health
|
||||
}
|
||||
edges {
|
||||
id
|
||||
from
|
||||
to
|
||||
type
|
||||
latency
|
||||
bandwidth
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
104
src/lib/graphql/queries/well-architected.ts
Normal file
104
src/lib/graphql/queries/well-architected.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { gql } from '@apollo/client'
|
||||
|
||||
// Query for getting all pillars
|
||||
export const GET_PILLARS = gql`
|
||||
query GetPillars {
|
||||
pillars {
|
||||
id
|
||||
code
|
||||
name
|
||||
description
|
||||
controls {
|
||||
id
|
||||
code
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Query for getting a specific pillar
|
||||
export const GET_PILLAR = gql`
|
||||
query GetPillar($code: PillarCode!) {
|
||||
pillar(code: $code) {
|
||||
id
|
||||
code
|
||||
name
|
||||
description
|
||||
controls {
|
||||
id
|
||||
code
|
||||
name
|
||||
description
|
||||
findings {
|
||||
id
|
||||
resource {
|
||||
id
|
||||
name
|
||||
}
|
||||
status
|
||||
severity
|
||||
title
|
||||
description
|
||||
recommendation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Query for getting findings
|
||||
export const GET_FINDINGS = gql`
|
||||
query GetFindings($filter: FindingFilter) {
|
||||
findings(filter: $filter) {
|
||||
id
|
||||
control {
|
||||
id
|
||||
code
|
||||
name
|
||||
pillar {
|
||||
code
|
||||
name
|
||||
}
|
||||
}
|
||||
resource {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
status
|
||||
severity
|
||||
title
|
||||
description
|
||||
recommendation
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Query for getting risks
|
||||
export const GET_RISKS = gql`
|
||||
query GetRisks($resourceId: ID) {
|
||||
risks(resourceId: $resourceId) {
|
||||
id
|
||||
resource {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
pillar {
|
||||
code
|
||||
name
|
||||
}
|
||||
severity
|
||||
title
|
||||
description
|
||||
mitigation
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
153
src/lib/graphql/schema/types.ts
Normal file
153
src/lib/graphql/schema/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* GraphQL TypeScript types
|
||||
* These types should match the GraphQL schema
|
||||
*/
|
||||
|
||||
export type ResourceType =
|
||||
| 'REGION'
|
||||
| 'SITE'
|
||||
| 'CLUSTER'
|
||||
| 'NODE'
|
||||
| 'VM'
|
||||
| 'POD'
|
||||
| 'SERVICE'
|
||||
| 'NETWORK'
|
||||
| 'STORAGE'
|
||||
| 'TUNNEL'
|
||||
| 'POLICY'
|
||||
|
||||
export type PillarCode =
|
||||
| 'SECURITY'
|
||||
| 'RELIABILITY'
|
||||
| 'COST_OPTIMIZATION'
|
||||
| 'PERFORMANCE_EFFICIENCY'
|
||||
| 'OPERATIONAL_EXCELLENCE'
|
||||
| 'SUSTAINABILITY'
|
||||
|
||||
export type HealthStatus = 'HEALTHY' | 'DEGRADED' | 'UNHEALTHY' | 'UNKNOWN'
|
||||
|
||||
export type FindingStatus = 'PASS' | 'FAIL' | 'WARNING' | 'INFO' | 'NOT_APPLICABLE'
|
||||
|
||||
export type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO'
|
||||
|
||||
export type MetricType =
|
||||
| 'CPU_USAGE'
|
||||
| 'MEMORY_USAGE'
|
||||
| 'NETWORK_THROUGHPUT'
|
||||
| 'NETWORK_LATENCY'
|
||||
| 'STORAGE_IOPS'
|
||||
| 'REQUEST_RATE'
|
||||
| 'ERROR_RATE'
|
||||
| 'COST'
|
||||
| 'HEALTH_SCORE'
|
||||
|
||||
export interface Region {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
country?: string
|
||||
coordinates?: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
sites?: Site[]
|
||||
clusters?: Cluster[]
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
name: string
|
||||
region: Region
|
||||
clusters?: Cluster[]
|
||||
}
|
||||
|
||||
export interface Cluster {
|
||||
id: string
|
||||
name: string
|
||||
region?: Region
|
||||
site?: Site
|
||||
nodes?: Node[]
|
||||
services?: Service[]
|
||||
health: HealthStatus
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string
|
||||
name: string
|
||||
cluster: Cluster
|
||||
health: HealthStatus
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string
|
||||
name: string
|
||||
cluster?: Cluster
|
||||
dependencies?: Service[]
|
||||
health: HealthStatus
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string
|
||||
name: string
|
||||
type: ResourceType
|
||||
region?: Region
|
||||
health?: HealthStatus
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Pillar {
|
||||
id: string
|
||||
code: PillarCode
|
||||
name: string
|
||||
description?: string
|
||||
controls?: Control[]
|
||||
}
|
||||
|
||||
export interface Control {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
pillar: Pillar
|
||||
description?: string
|
||||
findings?: Finding[]
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string
|
||||
control: Control
|
||||
resource: Resource
|
||||
status: FindingStatus
|
||||
severity: Severity
|
||||
title: string
|
||||
description?: string
|
||||
recommendation?: string
|
||||
}
|
||||
|
||||
export interface Risk {
|
||||
id: string
|
||||
resource: Resource
|
||||
pillar?: Pillar
|
||||
severity: Severity
|
||||
title: string
|
||||
description?: string
|
||||
mitigation?: string
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
resource: Resource
|
||||
metricType: MetricType
|
||||
values: MetricValue[]
|
||||
timeRange: TimeRange
|
||||
}
|
||||
|
||||
export interface MetricValue {
|
||||
timestamp: string
|
||||
value: number
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
75
src/lib/graphql/subscriptions.ts
Normal file
75
src/lib/graphql/subscriptions.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { gql } from '@apollo/client'
|
||||
|
||||
// Subscription for resource updates
|
||||
export const RESOURCE_UPDATED = gql`
|
||||
subscription ResourceUpdated($resourceId: ID!) {
|
||||
resourceUpdated(resourceId: $resourceId) {
|
||||
id
|
||||
name
|
||||
type
|
||||
health
|
||||
metadata
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Subscription for metrics updates
|
||||
export const METRICS_UPDATED = gql`
|
||||
subscription MetricsUpdated($resourceId: ID!, $metricType: MetricType!) {
|
||||
metricsUpdated(resourceId: $resourceId, metricType: $metricType) {
|
||||
timestamp
|
||||
value
|
||||
labels
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Subscription for health changes
|
||||
export const HEALTH_CHANGED = gql`
|
||||
subscription HealthChanged($resourceId: ID!) {
|
||||
healthChanged(resourceId: $resourceId)
|
||||
}
|
||||
`
|
||||
|
||||
// Subscription for new findings
|
||||
export const FINDING_CREATED = gql`
|
||||
subscription FindingCreated($controlId: ID) {
|
||||
findingCreated(controlId: $controlId) {
|
||||
id
|
||||
control {
|
||||
id
|
||||
code
|
||||
name
|
||||
}
|
||||
resource {
|
||||
id
|
||||
name
|
||||
}
|
||||
status
|
||||
severity
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Subscription for new risks
|
||||
export const RISK_CREATED = gql`
|
||||
subscription RiskCreated($resourceId: ID) {
|
||||
riskCreated(resourceId: $resourceId) {
|
||||
id
|
||||
resource {
|
||||
id
|
||||
name
|
||||
}
|
||||
pillar {
|
||||
code
|
||||
name
|
||||
}
|
||||
severity
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
94
src/lib/logger.ts
Normal file
94
src/lib/logger.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Structured logging utility
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel
|
||||
message: string
|
||||
timestamp: string
|
||||
context?: Record<string, unknown>
|
||||
error?: Error
|
||||
}
|
||||
|
||||
class Logger {
|
||||
private logLevel: LogLevel
|
||||
|
||||
constructor() {
|
||||
this.logLevel = (process.env.NODE_ENV === 'development' ? 'debug' : 'info') as LogLevel
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
const levels: LogLevel[] = ['debug', 'info', 'warn', 'error']
|
||||
return levels.indexOf(level) >= levels.indexOf(this.logLevel)
|
||||
}
|
||||
|
||||
private formatEntry(entry: LogEntry): string {
|
||||
const { level, message, timestamp, context, error } = entry
|
||||
const parts = [`[${timestamp}]`, `[${level.toUpperCase()}]`, message]
|
||||
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
parts.push(JSON.stringify(context))
|
||||
}
|
||||
|
||||
if (error) {
|
||||
parts.push(`\nError: ${error.message}`)
|
||||
if (error.stack) {
|
||||
parts.push(`\nStack: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, context?: Record<string, unknown>, error?: Error) {
|
||||
if (!this.shouldLog(level)) {
|
||||
return
|
||||
}
|
||||
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
context,
|
||||
error,
|
||||
}
|
||||
|
||||
const formatted = this.formatEntry(entry)
|
||||
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(formatted)
|
||||
break
|
||||
case 'info':
|
||||
console.info(formatted)
|
||||
break
|
||||
case 'warn':
|
||||
console.warn(formatted)
|
||||
break
|
||||
case 'error':
|
||||
console.error(formatted)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
debug(message: string, context?: Record<string, unknown>) {
|
||||
this.log('debug', message, context)
|
||||
}
|
||||
|
||||
info(message: string, context?: Record<string, unknown>) {
|
||||
this.log('info', message, context)
|
||||
}
|
||||
|
||||
warn(message: string, context?: Record<string, unknown>) {
|
||||
this.log('warn', message, context)
|
||||
}
|
||||
|
||||
error(message: string, error?: Error, context?: Record<string, unknown>) {
|
||||
this.log('error', message, context, error)
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger()
|
||||
|
||||
87
src/lib/monitoring.ts
Normal file
87
src/lib/monitoring.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Application monitoring and error tracking
|
||||
*/
|
||||
|
||||
let sentryInitialized = false
|
||||
|
||||
/**
|
||||
* Initialize Sentry for error tracking
|
||||
*/
|
||||
export function initMonitoring() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
if (!dsn || sentryInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize Sentry in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// Sentry will be initialized via sentry.client.config.ts
|
||||
sentryInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error to monitoring service
|
||||
*/
|
||||
export function logError(error: Error, context?: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined') {
|
||||
console.error('Error:', error, context)
|
||||
return
|
||||
}
|
||||
|
||||
// In production, this will send to Sentry
|
||||
if (process.env.NODE_ENV === 'production' && typeof window !== 'undefined') {
|
||||
// @ts-expect-error - Sentry will be available
|
||||
if (window.Sentry) {
|
||||
// @ts-expect-error
|
||||
window.Sentry.captureException(error, { extra: context })
|
||||
}
|
||||
} else {
|
||||
console.error('Error:', error, context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log performance metric
|
||||
*/
|
||||
export function logPerformance(name: string, duration: number, metadata?: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`Performance: ${name} took ${duration}ms`, metadata)
|
||||
}
|
||||
|
||||
// In production, send to monitoring service
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// @ts-expect-error - Sentry will be available
|
||||
if (window.Sentry) {
|
||||
// @ts-expect-error
|
||||
window.Sentry.metrics.distribution(name, duration, { tags: metadata })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
*/
|
||||
export function trackEvent(name: string, properties?: Record<string, unknown>) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// In production, send to analytics
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// Add analytics tracking here
|
||||
console.log('Event:', name, properties)
|
||||
} else {
|
||||
console.log('Event:', name, properties)
|
||||
}
|
||||
}
|
||||
|
||||
81
src/lib/performance.ts
Normal file
81
src/lib/performance.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Performance monitoring and optimization utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Measure performance of a function
|
||||
*/
|
||||
export async function measurePerformance<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T> | T
|
||||
): Promise<T> {
|
||||
const start = performance.now()
|
||||
try {
|
||||
const result = await fn()
|
||||
const duration = performance.now() - start
|
||||
if (typeof window !== 'undefined') {
|
||||
const { logPerformance } = await import('./monitoring')
|
||||
logPerformance(name, duration)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
const duration = performance.now() - start
|
||||
console.error(`Performance error for ${name}:`, error, `(took ${duration}ms)`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy load a component
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function lazyLoad<T extends React.ComponentType<any>>(
|
||||
importFn: () => Promise<{ default: T }>
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
|
||||
const React = require('react')
|
||||
return React.lazy(importFn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
timeout = null
|
||||
func(...args)
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle: boolean
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => (inThrottle = false), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
src/lib/test-helpers.ts
Normal file
30
src/lib/test-helpers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Test helper utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mock window.matchMedia
|
||||
*/
|
||||
export function mockMatchMedia() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for async operations to complete
|
||||
*/
|
||||
export function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
9
src/lib/test-setup.ts
Normal file
9
src/lib/test-setup.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
37
src/lib/test-utils.tsx
Normal file
37
src/lib/test-utils.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { ReactElement } from 'react'
|
||||
import { render, RenderOptions } from '@testing-library/react'
|
||||
import { ApolloProvider } from '@apollo/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { apolloClient } from './graphql/client'
|
||||
|
||||
// Create a test query client
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
interface AllTheProvidersProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const AllTheProviders = ({ children }: AllTheProvidersProps) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
|
||||
return (
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApolloProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
|
||||
render(ui, { wrapper: AllTheProviders, ...options })
|
||||
|
||||
export * from '@testing-library/react'
|
||||
export { customRender as render }
|
||||
|
||||
65
src/lib/types/errors.ts
Normal file
65
src/lib/types/errors.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Error types and utilities
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode?: number,
|
||||
public cause?: Error
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
Object.setPrototypeOf(this, AppError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends AppError {
|
||||
constructor(message: string, cause?: Error) {
|
||||
super(message, 'NETWORK_ERROR', 0, cause)
|
||||
this.name = 'NetworkError'
|
||||
Object.setPrototypeOf(this, NetworkError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, public fields?: Record<string, string[]>) {
|
||||
super(message, 'VALIDATION_ERROR', 400)
|
||||
this.name = 'ValidationError'
|
||||
Object.setPrototypeOf(this, ValidationError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends AppError {
|
||||
constructor(message: string = 'Authentication required') {
|
||||
super(message, 'AUTH_ERROR', 401)
|
||||
this.name = 'AuthenticationError'
|
||||
Object.setPrototypeOf(this, AuthenticationError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthorizationError extends AppError {
|
||||
constructor(message: string = 'Insufficient permissions') {
|
||||
super(message, 'AUTHZ_ERROR', 403)
|
||||
this.name = 'AuthorizationError'
|
||||
Object.setPrototypeOf(this, AuthorizationError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message: string = 'Resource not found') {
|
||||
super(message, 'NOT_FOUND', 404)
|
||||
this.name = 'NotFoundError'
|
||||
Object.setPrototypeOf(this, NotFoundError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends AppError {
|
||||
constructor(message: string = 'Internal server error', cause?: Error) {
|
||||
super(message, 'SERVER_ERROR', 500, cause)
|
||||
this.name = 'ServerError'
|
||||
Object.setPrototypeOf(this, ServerError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
22
src/lib/utils.test.ts
Normal file
22
src/lib/utils.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { cn } from './utils'
|
||||
|
||||
describe('cn utility', () => {
|
||||
it('should merge class names', () => {
|
||||
expect(cn('foo', 'bar')).toBe('foo bar')
|
||||
})
|
||||
|
||||
it('should handle conditional classes', () => {
|
||||
expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz')
|
||||
})
|
||||
|
||||
it('should merge Tailwind classes correctly', () => {
|
||||
expect(cn('p-4 p-2')).toBe('p-2')
|
||||
})
|
||||
|
||||
it('should handle empty inputs', () => {
|
||||
expect(cn()).toBe('')
|
||||
expect(cn('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
10
src/lib/utils.ts
Normal file
10
src/lib/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
82
src/lib/validation/schemas.ts
Normal file
82
src/lib/validation/schemas.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Zod validation schemas
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Common validation schemas
|
||||
*/
|
||||
export const emailSchema = z.string().email('Invalid email address')
|
||||
|
||||
export const urlSchema = z.string().url('Invalid URL')
|
||||
|
||||
export const nonEmptyStringSchema = z.string().min(1, 'This field is required')
|
||||
|
||||
/**
|
||||
* Resource validation schemas
|
||||
*/
|
||||
export const resourceNameSchema = z
|
||||
.string()
|
||||
.min(1, 'Resource name is required')
|
||||
.max(63, 'Resource name must be 63 characters or less')
|
||||
.regex(
|
||||
/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/,
|
||||
'Resource name must be lowercase alphanumeric with hyphens'
|
||||
)
|
||||
|
||||
export const memorySchema = z
|
||||
.string()
|
||||
.regex(/^\d+[KMGT]i?$/, 'Memory must be in format like 4Gi, 8Mi, etc.')
|
||||
|
||||
export const diskSizeSchema = z
|
||||
.string()
|
||||
.regex(/^\d+[KMGT]i?$/, 'Disk size must be in format like 50Gi, 100Mi, etc.')
|
||||
|
||||
export const cpuSchema = z.number().int().min(1).max(128)
|
||||
|
||||
/**
|
||||
* VM creation schema
|
||||
*/
|
||||
export const vmCreateSchema = z.object({
|
||||
name: resourceNameSchema,
|
||||
node: nonEmptyStringSchema,
|
||||
cpu: cpuSchema,
|
||||
memory: memorySchema,
|
||||
disk: diskSizeSchema,
|
||||
storage: nonEmptyStringSchema,
|
||||
network: nonEmptyStringSchema,
|
||||
image: nonEmptyStringSchema,
|
||||
site: nonEmptyStringSchema,
|
||||
userData: z.string().optional(),
|
||||
sshKeys: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type VMCreateInput = z.infer<typeof vmCreateSchema>
|
||||
|
||||
/**
|
||||
* Form validation utilities
|
||||
*/
|
||||
export function validateForm<T>(schema: z.ZodSchema<T>, data: unknown): {
|
||||
success: boolean
|
||||
data?: T
|
||||
errors?: Record<string, string[]>
|
||||
} {
|
||||
const result = schema.safeParse(data)
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
const errors: Record<string, string[]> = {}
|
||||
result.error.errors.forEach((error) => {
|
||||
const path = error.path.join('.')
|
||||
if (!errors[path]) {
|
||||
errors[path] = []
|
||||
}
|
||||
errors[path].push(error.message)
|
||||
})
|
||||
|
||||
return { success: false, errors }
|
||||
}
|
||||
|
||||
74
src/types/index.ts
Normal file
74
src/types/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Shared TypeScript type definitions
|
||||
*/
|
||||
|
||||
// API Types
|
||||
export interface Resource {
|
||||
id: string
|
||||
name: string
|
||||
type: ResourceType
|
||||
status: ResourceStatus
|
||||
site: Site
|
||||
metadata?: Record<string, unknown>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string
|
||||
name: string
|
||||
region: string
|
||||
status: SiteStatus
|
||||
resources?: Resource[]
|
||||
metadata?: Record<string, unknown>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: UserRole
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type ResourceType = 'VM' | 'CONTAINER' | 'STORAGE' | 'NETWORK'
|
||||
export type ResourceStatus = 'PENDING' | 'PROVISIONING' | 'RUNNING' | 'STOPPED' | 'ERROR' | 'DELETING'
|
||||
export type SiteStatus = 'ACTIVE' | 'INACTIVE' | 'MAINTENANCE'
|
||||
export type UserRole = 'ADMIN' | 'USER' | 'VIEWER'
|
||||
|
||||
// Form Types
|
||||
export interface ResourceFormData {
|
||||
name: string
|
||||
type: ResourceType
|
||||
siteId: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface ApiResponse<T> {
|
||||
data?: T
|
||||
errors?: Array<{
|
||||
message: string
|
||||
code?: string
|
||||
path?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
// Pagination Types
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user