mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-25 08:27:57 +00:00
feat: Phase 1B complete - Perwerde & ValidatorPool UI
Perwerde (Education Platform): - Add hybrid backend (Supabase + Blockchain + IPFS) - Implement CourseList, CourseCreator, StudentDashboard - Create courses table with RLS policies - Add IPFS upload utility - Integrate with pallet-perwerde extrinsics ValidatorPool: - Add validator pool management UI - Implement PoolCategorySelector with 3 categories - Add ValidatorPoolDashboard with pool stats - Integrate with pallet-validator-pool extrinsics - Add to StakingDashboard as new tab Technical: - Fix all toast imports (sonner) - Fix IPFS File upload (Blob conversion) - Fix RLS policies (wallet_address → auth.uid) - Add error boundaries - Add loading states Status: UI complete, blockchain integration pending VPS deployment
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { toast } from 'sonner';
|
||||
import { createCourse } from '@shared/lib/perwerde';
|
||||
import { uploadToIPFS } from '@shared/lib/ipfs';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface CourseCreatorProps {
|
||||
onCourseCreated: () => void;
|
||||
}
|
||||
|
||||
export function CourseCreator({ onCourseCreated }: CourseCreatorProps) {
|
||||
const { api, selectedAccount } = usePolkadot();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCreateCourse = async () => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast.error('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name || !description || !content) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 1. Upload content to IPFS
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const file = new File([blob], 'course-content.md', { type: 'text/markdown' });
|
||||
|
||||
let ipfsHash: string;
|
||||
try {
|
||||
ipfsHash = await uploadToIPFS(file);
|
||||
toast.success(`Content uploaded: ${ipfsHash.slice(0, 10)}...`);
|
||||
} catch (ipfsError) {
|
||||
toast.error('IPFS upload failed');
|
||||
return; // STOP - don't call blockchain
|
||||
}
|
||||
|
||||
// 2. Create course on blockchain
|
||||
await createCourse(api, selectedAccount, name, description, ipfsHash);
|
||||
|
||||
// 3. Reset form
|
||||
onCourseCreated();
|
||||
setName('');
|
||||
setDescription('');
|
||||
setContent('');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create course:', error);
|
||||
// toast already shown in createCourse()
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Create New Course</CardTitle>
|
||||
<CardDescription>Fill in the details to create a new course on the Perwerde platform.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-white">Course Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Introduction to Blockchain"
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-white">Course Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="A brief summary of the course content and objectives."
|
||||
className="bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content" className="text-white">Course Content (Markdown)</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Write your course material here using Markdown..."
|
||||
className="bg-gray-800 border-gray-700 text-white h-48"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateCourse}
|
||||
disabled={loading || !selectedAccount}
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Course'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { GraduationCap, BookOpen, ExternalLink, Play } from 'lucide-react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { toast } from 'sonner';
|
||||
import { LoadingState } from '@shared/components/AsyncComponent';
|
||||
import { getCourses, enrollInCourse, type Course } from '@shared/lib/perwerde';
|
||||
import { getIPFSUrl } from '@shared/lib/ipfs';
|
||||
|
||||
interface CourseListProps {
|
||||
enrolledCourseIds: number[];
|
||||
onEnroll: () => void;
|
||||
}
|
||||
|
||||
export function CourseList({ enrolledCourseIds, onEnroll }: CourseListProps) {
|
||||
const { api, selectedAccount } = usePolkadot();
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCourses = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const activeCourses = await getCourses('Active');
|
||||
setCourses(activeCourses);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch courses:', error);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to fetch courses',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
||||
const handleEnroll = async (courseId: number) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Please connect your wallet first',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await enrollInCourse(api, selectedAccount, courseId);
|
||||
onEnroll();
|
||||
} catch (error: any) {
|
||||
console.error('Enroll failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading available courses..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
{courses.length > 0 ? `Available Courses (${courses.length})` : 'No Courses Available'}
|
||||
</h2>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-12 text-center">
|
||||
<BookOpen className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-400 mb-2">No Active Courses</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Check back later for new educational content.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-6">
|
||||
{courses.map((course) => {
|
||||
const isUserEnrolled = enrolledCourseIds.includes(course.id);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={course.id}
|
||||
className="bg-gray-900 border-gray-800 hover:border-green-500/50 transition-colors"
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-bold text-white">{course.name}</h3>
|
||||
<Badge className="bg-green-500/10 text-green-400 border-green-500/30">
|
||||
#{course.id}
|
||||
</Badge>
|
||||
{isUserEnrolled && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/30">
|
||||
Enrolled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 mb-4">{course.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
{course.owner.slice(0, 8)}...{course.owner.slice(-6)}
|
||||
</div>
|
||||
{course.content_link && (
|
||||
<a
|
||||
href={getIPFSUrl(course.content_link)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-green-400 hover:text-green-300"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Course Materials
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{isUserEnrolled ? (
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" disabled>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Already Enrolled
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => handleEnroll(course.id)}
|
||||
disabled={!selectedAccount}
|
||||
>
|
||||
Enroll Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { BookOpen, CheckCircle, Award } from 'lucide-react';
|
||||
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||
import { toast } from 'sonner';
|
||||
import { LoadingState } from '@shared/components/AsyncComponent';
|
||||
import { getStudentEnrollments, completeCourse, type Enrollment } from '@shared/lib/perwerde';
|
||||
|
||||
interface StudentDashboardProps {
|
||||
enrollments: Enrollment[];
|
||||
loading: boolean;
|
||||
onCourseCompleted: () => void;
|
||||
}
|
||||
|
||||
export function StudentDashboard({ enrollments, loading, onCourseCompleted }: StudentDashboardProps) {
|
||||
const { api, selectedAccount } = usePolkadot();
|
||||
|
||||
const handleComplete = async (courseId: number) => {
|
||||
if (!api || !selectedAccount) {
|
||||
toast.error('Please connect your wallet first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For now, let's assume a fixed number of points for completion
|
||||
const points = 10;
|
||||
await completeCourse(api, selectedAccount, courseId, points);
|
||||
onCourseCompleted();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to complete course:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading your dashboard..." />;
|
||||
}
|
||||
|
||||
const completedCourses = enrollments.filter(e => e.is_completed).length;
|
||||
const totalPoints = enrollments.reduce((sum, e) => sum + e.points_earned, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<BookOpen className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{enrollments.length}</div>
|
||||
<div className="text-sm text-gray-400">Enrolled Courses</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{completedCourses}</div>
|
||||
<div className="text-sm text-gray-400">Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-yellow-500/10 flex items-center justify-center">
|
||||
<Award className="w-6 h-6 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white">{totalPoints}</div>
|
||||
<div className="text-sm text-gray-400">Total Points</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-900 border-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">My Courses</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{enrollments.length === 0 ? (
|
||||
<p className="text-gray-400">You are not enrolled in any courses yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{enrollments.map(enrollment => (
|
||||
<div key={enrollment.id} className="p-4 bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-bold text-white">Course #{enrollment.course_id}</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
Enrolled on: {new Date(enrollment.enrolled_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{enrollment.is_completed ? (
|
||||
<Badge className="bg-green-500/10 text-green-400">Completed</Badge>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => handleComplete(enrollment.course_id)}>
|
||||
Mark as Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user