Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions src/app/courses/[courseId]/modules/[moduleId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useModule, useCourseDetail } from "@/lib/hooks/use-courses";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { BackButton } from "@/components/shared/back-button";
import { ProgressBar } from "@/components/course/progress-bar";
import { useCourseStore } from "@/store/course-store";
import { useToastContext } from "@/components/shared/toast";
Expand Down Expand Up @@ -83,15 +84,7 @@ export default function ModulePage({
return (
<div className="mx-auto max-w-3xl px-4 py-8">
{/* Breadcrumb */}
<div className="mb-6">
<Link
href={`/courses/${courseId}`}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ArrowLeft className="h-4 w-4" />
Back to Course
</Link>
</div>
<BackButton />

{/* Progress */}
{courseProgress && (
Expand Down
16 changes: 12 additions & 4 deletions src/app/courses/[courseId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge, difficultyVariant } from "@/components/ui/badge";
import { AutoBreadcrumb } from "@/components/ui/breadcrumb";
import { BackButton } from "@/components/shared/back-button";
import { ShareButton } from "@/components/shared/share-button";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { useToastContext } from "@/components/shared/toast";
import {
Expand Down Expand Up @@ -86,10 +88,16 @@ export default function CourseDetailPage({

return (
<div className="mx-auto max-w-4xl px-4 py-8">
<AutoBreadcrumb
className="mb-6"
labels={{ [courseId]: course.title }}
/>
<BackButton />
<div className="flex items-center justify-between mb-6">
<AutoBreadcrumb
labels={{ [courseId]: course.title }}
/>
<ShareButton
url={typeof window !== "undefined" ? window.location.href : ""}
title={course.title}
/>
</div>

{/* Hero */}
<div className="mb-8">
Expand Down
11 changes: 2 additions & 9 deletions src/app/courses/[courseId]/quiz/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { lazy, Suspense, useState, useCallback } from "react";
import { useQuiz } from "@/lib/hooks/use-quiz";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { BackButton } from "@/components/shared/back-button";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
Expand Down Expand Up @@ -79,15 +80,7 @@ export default function QuizPage({

return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-6">
<Link
href={`/courses/${courseId}`}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="h-4 w-4" />
Back to Course
</Link>
</div>
<BackButton />

<Suspense fallback={<LoadingSkeleton count={3} />}>
<QuizInterface
Expand Down
17 changes: 10 additions & 7 deletions src/app/credentials/[credentialId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from "next/link";
import { useCredentialDetail } from "@/lib/hooks/use-credentials";
import { CredentialBadge } from "@/components/credentials/credential-badge";
import { QrCode } from "@/components/credentials/qr-code";
import { BackButton } from "@/components/shared/back-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
Expand All @@ -22,6 +23,7 @@ import {
Key,
} from "lucide-react";
import { useState, useCallback } from "react";
import { ShareButton } from "@/components/shared/share-button";

export default function CredentialDetailPage({
params,
Expand Down Expand Up @@ -111,6 +113,7 @@ export default function CredentialDetailPage({

return (
<div className="mx-auto max-w-2xl px-4 py-8">
<BackButton />
{/* Badge */}
<div className="flex justify-center mb-8">
<CredentialBadge
Expand Down Expand Up @@ -250,13 +253,13 @@ export default function CredentialDetailPage({
<Download className="h-4 w-4" />
Download Certificate
</button>
<Link
href={`/verify/${credentialId}`}
className="flex items-center justify-center gap-2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800 transition-colors"
>
<ExternalLink className="h-4 w-4" />
Share Verification Link
</Link>
<ShareButton
url={verificationUrl}
title={credential.courseTitle}
text="Check out my verified credential!"
variant="default"
className="w-full flex items-center justify-center rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800 transition-colors"
/>
</div>
</CardContent>
</Card>
Expand Down
15 changes: 15 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/hooks/use-auth";
import { useToastContext } from "@/components/shared/toast";
import { getProfile, updateProfile } from "@/lib/api/auth";
import { AvatarUpload } from "@/components/shared/avatar-upload";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Expand Down Expand Up @@ -115,6 +116,20 @@ export default function SettingsPage() {
<CardTitle className="text-lg">Profile Information</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar Upload */}
<div className="flex justify-center mb-6">
<AvatarUpload
currentAvatarUrl={undefined} // We could load this from profile if the API supported it
name={displayName || walletAddress || "User"}
onUpload={async (file) => {
// In a real app, upload the file to storage and update profile
// For now, simulate upload
await new Promise((resolve) => setTimeout(resolve, 1000));
addToast("Avatar uploaded successfully", "success");
}}
/>
</div>

{/* Display Name */}
<div className="space-y-2">
<label
Expand Down
5 changes: 4 additions & 1 deletion src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ export function Header() {
{isAuthenticated && walletAddress && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 outline-none hover:opacity-80 transition-opacity">
<button
className="flex items-center gap-2 outline-none hover:opacity-80 transition-opacity"
aria-label="User account menu"
>
<Avatar size="sm">
<AvatarFallback>{getInitials(walletAddress)}</AvatarFallback>
</Avatar>
Expand Down
178 changes: 178 additions & 0 deletions src/components/shared/avatar-upload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
'use client';

import React, { useState, useCallback, useRef } from 'react';
import Cropper, { Area } from 'react-easy-crop';
import { Camera, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback, getInitials } from '@/components/ui/avatar';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { useToastContext } from '@/components/shared/toast';

const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous');
image.src = url;
});

async function getCroppedImg(
imageSrc: string,
pixelCrop: Area
): Promise<File | null> {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return null;

canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;

ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);

return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (!blob) {
resolve(null);
return;
}
resolve(new File([blob], 'avatar.jpg', { type: 'image/jpeg' }));
}, 'image/jpeg');
});
}

interface AvatarUploadProps {
currentAvatarUrl?: string;
name: string;
onUpload: (file: File) => Promise<void>;
}

export function AvatarUpload({ currentAvatarUrl, name, onUpload }: AvatarUploadProps) {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isCropDialogOpen, setIsCropDialogOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { addToast } = useToastContext();

const onFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
if (!file.type.startsWith('image/')) {
addToast('Please select an image file', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
addToast('Image must be less than 5MB', 'error');
return;
}
const imageDataUrl = URL.createObjectURL(file);
setImageSrc(imageDataUrl);
setIsCropDialogOpen(true);
}
};

const onCropComplete = useCallback((croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);

const handleSave = async () => {
if (!imageSrc || !croppedAreaPixels) return;

try {
setIsUploading(true);
const croppedImageFile = await getCroppedImg(imageSrc, croppedAreaPixels);
if (croppedImageFile) {
await onUpload(croppedImageFile);
addToast('Avatar updated successfully', 'success');
setIsCropDialogOpen(false);
setImageSrc(null);
}
} catch (e) {
console.error(e);
addToast('Failed to crop and upload image', 'error');
} finally {
setIsUploading(false);
}
};

return (
<div>
<div className="relative inline-block">
<Avatar size="lg" className="h-24 w-24 border-2 border-white shadow-sm dark:border-gray-900">
<AvatarImage src={currentAvatarUrl} alt={name} />
<AvatarFallback className="text-xl">{getInitials(name)}</AvatarFallback>
</Avatar>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute bottom-0 right-0 flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
aria-label="Upload new avatar"
>
<Camera className="h-4 w-4" />
</button>
<input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={onFileChange}
className="hidden"
aria-hidden="true"
/>
</div>

<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Crop Image</DialogTitle>
</DialogHeader>
<div className="relative h-64 w-full sm:h-80 bg-gray-900 overflow-hidden rounded-md">
{imageSrc && (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCropDialogOpen(false)}
disabled={isUploading}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={isUploading}>
{isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Avatar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
22 changes: 22 additions & 0 deletions src/components/shared/back-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';

export function BackButton() {
const router = useRouter();

return (
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="mb-4 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
aria-label="Go back to previous page"
>
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
Back
</Button>
);
}
2 changes: 1 addition & 1 deletion src/components/shared/loading-skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function LoadingSkeleton({
variant = "card",
}: LoadingSkeletonProps) {
return (
<div className={cn("space-y-3", className)}>
<div className={cn("space-y-3", className)} aria-live="polite" aria-busy="true">
{Array.from({ length: count }).map((_, i) => {
if (variant === "text") {
return <SkeletonText key={i} />;
Expand Down
Loading