Alexis Bruneteau b83c7a7d6d
Some checks failed
Build and Deploy to k3s / build-and-deploy (push) Failing after 1m31s
feat(migration): migrate from Angular 20 to Next.js 15
Complete framework migration from Angular to Next.js with full feature parity.

## What Changed
- Migrated from Angular 20 to Next.js 15 with App Router
- Replaced Angular components with React functional components
- Implemented React Context API for state management (replacing RxJS)
- Integrated React Hook Form for form handling
- Added shadcn/ui component library
- Configured Tailwind CSS 4.1 with @tailwindcss/postcss
- Implemented JWT authentication with middleware protection

## Core Features Implemented
-  User registration and login with validation
-  JWT token authentication with localStorage
-  Protected dashboard route with middleware
-  Portfolio listing with status indicators
-  Create portfolio functionality
-  ZIP file upload with progress tracking
-  Portfolio deployment
-  Responsive design with Tailwind CSS

## Technical Stack
- Framework: Next.js 15 (App Router)
- Language: TypeScript 5.8
- Styling: Tailwind CSS 4.1
- UI Components: shadcn/ui + Lucide icons
- State: React Context API + hooks
- Forms: React Hook Form
- API Client: Native fetch with custom wrapper

## File Structure
- /app - Next.js pages (landing, login, register, dashboard)
- /components - React components (ui primitives, auth provider)
- /lib - API client, types, utilities
- /hooks - Custom hooks (useAuth, usePortfolios)
- /middleware.ts - Route protection
- /angular-backup - Preserved Angular source code

## API Compatibility
- All backend endpoints remain unchanged
- JWT Bearer token authentication preserved
- API response format maintained

## Build Output
- Production build: 7 routes compiled successfully
- Bundle size: ~102kB shared JS (optimized)
- First Load JS: 103-126kB per route

## Documentation
- Updated README.md with Next.js setup guide
- Created OpenSpec proposal in openspec/changes/migrate-to-nextjs-launchui/
- Added project context in openspec/project.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-17 00:34:43 +02:00

239 lines
8.9 KiB
TypeScript

'use client';
import { useState, useRef } from 'react';
import { useAuth } from '@/hooks/use-auth';
import { usePortfolios } from '@/hooks/use-portfolios';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Upload, Rocket, Plus, LogOut } from 'lucide-react';
export default function DashboardPage() {
const { user, logout } = useAuth();
const {
portfolios,
isLoading,
error,
createPortfolio,
uploadPortfolio,
deployPortfolio,
} = usePortfolios();
const [showCreateForm, setShowCreateForm] = useState(false);
const [newPortfolio, setNewPortfolio] = useState({ name: '', domain: '' });
const [uploadingId, setUploadingId] = useState<number | null>(null);
const [deployingId, setDeployingId] = useState<number | null>(null);
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createPortfolio(newPortfolio.name, newPortfolio.domain);
setNewPortfolio({ name: '', domain: '' });
setShowCreateForm(false);
} catch (err) {
console.error('Failed to create portfolio:', err);
}
};
const handleUpload = async (id: number, file: File) => {
try {
setUploadingId(id);
await uploadPortfolio(id, file);
} catch (err) {
console.error('Failed to upload portfolio:', err);
} finally {
setUploadingId(null);
}
};
const handleDeploy = async (id: number) => {
try {
setDeployingId(id);
await deployPortfolio(id);
} catch (err) {
console.error('Failed to deploy portfolio:', err);
} finally {
setDeployingId(null);
}
};
const getStatusBadge = (portfolio: any) => {
if (!portfolio.active) {
return <span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">Pending Payment</span>;
}
if (!portfolio.path) {
return <span className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800">Pending Upload</span>;
}
return <span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800">Uploaded</span>;
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">Portfolio Dashboard</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">Welcome, {user?.name}</span>
<Button variant="outline" size="sm" onClick={logout}>
<LogOut className="mr-2" size={16} />
Logout
</Button>
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Portfolios</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{portfolios.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{portfolios.filter(p => p.active).length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Uploaded</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{portfolios.filter(p => p.path).length}
</div>
</CardContent>
</Card>
</div>
{/* Create Portfolio Button */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold">Your Portfolios</h2>
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus className="mr-2" size={16} />
New Portfolio
</Button>
</div>
{/* Create Portfolio Form */}
{showCreateForm && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Create New Portfolio</CardTitle>
<CardDescription>Enter the details for your new portfolio</CardDescription>
</CardHeader>
<form onSubmit={handleCreate}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Portfolio Name</Label>
<Input
id="name"
placeholder="My Portfolio"
value={newPortfolio.name}
onChange={(e) => setNewPortfolio({ ...newPortfolio, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
placeholder="myportfolio.com"
value={newPortfolio.domain}
onChange={(e) => setNewPortfolio({ ...newPortfolio, domain: e.target.value })}
required
/>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button type="submit">Create</Button>
<Button type="button" variant="outline" onClick={() => setShowCreateForm(false)}>
Cancel
</Button>
</CardFooter>
</form>
</Card>
)}
{/* Portfolios List */}
{isLoading ? (
<div className="text-center py-12">Loading portfolios...</div>
) : error ? (
<div className="text-center py-12 text-destructive">{error}</div>
) : portfolios.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No portfolios yet. Create your first one!
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{portfolios.map((portfolio) => (
<Card key={portfolio.id}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-lg">{portfolio.name}</CardTitle>
<CardDescription className="mt-1">{portfolio.domain}</CardDescription>
</div>
{getStatusBadge(portfolio)}
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
Created: {new Date(portfolio.created_at).toLocaleDateString()}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
{portfolio.active && !portfolio.path && (
<>
<input
type="file"
accept=".zip"
ref={(el) => { fileInputRefs.current[portfolio.id] = el; }}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(portfolio.id, file);
}}
/>
<Button
className="w-full"
onClick={() => fileInputRefs.current[portfolio.id]?.click()}
disabled={uploadingId === portfolio.id}
>
<Upload className="mr-2" size={16} />
{uploadingId === portfolio.id ? 'Uploading...' : 'Upload ZIP'}
</Button>
</>
)}
{portfolio.path && (
<Button
className="w-full"
onClick={() => handleDeploy(portfolio.id)}
disabled={deployingId === portfolio.id}
>
<Rocket className="mr-2" size={16} />
{deployingId === portfolio.id ? 'Deploying...' : 'Deploy'}
</Button>
)}
</CardFooter>
</Card>
))}
</div>
)}
</main>
</div>
);
}