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
3 changes: 3 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import CompletionFeatureProvider from "@/providers/CompletionFeatureProvider";
import DashboardFeatureProvider from "@/providers/DashboardFeatureProvider";
import ErrorBoundary from "@/components/error/ErrorBoundary";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import QueryProvider from "@/providers/QueryProvider";

const geistSans = Geist({
variable: "--font-geist-sans",
Expand All @@ -31,6 +32,7 @@ export default function RootLayout({
<NetworkStatusProvider>
<ErrorBoundary>
<StoreProvider>
<QueryProvider>
<ToastProvider>
<DashboardFeatureProvider>
<CompletionFeatureProvider>
Expand All @@ -40,6 +42,7 @@ export default function RootLayout({
</CompletionFeatureProvider>
</DashboardFeatureProvider>
</ToastProvider>
</QueryProvider>
</StoreProvider>
</ErrorBoundary>
</NetworkStatusProvider>
Expand Down
117 changes: 111 additions & 6 deletions frontend/app/puzzles/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,114 @@
import React from 'react'
'use client';

import React, { useState } from 'react';
import { usePuzzles } from '@/hooks/usePuzzles';
import PuzzleCard from '@/components/puzzles/PuzzleCard';
import FilterBar from '@/components/puzzles/FilterBar';
import type { PuzzleFilters } from '@/lib/types/puzzles';

const DEFAULT_FILTERS: PuzzleFilters = {
categoryId: '',
difficulty: 'ALL',
};

// Skeleton card to match PuzzleCard proportions
const PuzzleCardSkeleton: React.FC = () => (
<div className="bg-[#0A1628] border border-gray-800 rounded-xl p-5 flex flex-col gap-4 animate-pulse">
{/* Badge row */}
<div className="flex gap-2">
<div className="h-6 w-16 rounded-full bg-white/10" />
<div className="h-6 w-20 rounded-full bg-white/10" />
</div>
{/* Title */}
<div className="h-5 w-3/4 rounded bg-white/10" />
{/* Description lines */}
<div className="flex flex-col gap-2">
<div className="h-3.5 w-full rounded bg-white/10" />
<div className="h-3.5 w-5/6 rounded bg-white/10" />
</div>
{/* Footer */}
<div className="flex justify-between pt-2 border-t border-white/5">
<div className="h-5 w-20 rounded-full bg-white/10" />
<div className="h-4 w-12 rounded bg-white/10" />
</div>
</div>
);

const PuzzleListPage: React.FC = () => {
const [filters, setFilters] = useState<PuzzleFilters>(DEFAULT_FILTERS);

const queryParams = {
...(filters.categoryId ? { categoryId: filters.categoryId } : {}),
...(filters.difficulty !== 'ALL' ? { difficulty: filters.difficulty } : {}),
};

const { data: puzzles, isLoading, isError, refetch } = usePuzzles(queryParams);

const page = () => {
return (
<div>page</div>
)
}
<div className="min-h-screen bg-[#050C16] text-white px-4 py-8 md:px-8">
<div className="mx-auto max-w-6xl">

{/* Page header */}
<div className="mb-8">
<h1 className="text-2xl md:text-3xl font-bold text-[#E6E6E6]">Puzzles</h1>
<p className="text-gray-400 mt-1 text-sm">
Challenge yourself with logic, coding, and blockchain puzzles.
</p>
</div>

{/* Filter bar */}
<div className="mb-6">
<FilterBar filters={filters} onFiltersChange={setFilters} />
</div>

{/* Error state */}
{isError && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<p className="text-gray-400 text-sm">Failed to load puzzles.</p>
<button
onClick={() => refetch()}
className="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors"
>
Try again
</button>
</div>
)}

{/* Skeleton grid while loading */}
{isLoading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<PuzzleCardSkeleton key={i} />
))}
</div>
)}

{/* Puzzle grid */}
{!isLoading && !isError && (
<>
{puzzles && puzzles.length > 0 ? (
<>
<p className="text-xs text-gray-500 mb-4">
{puzzles.length} puzzle{puzzles.length !== 1 ? 's' : ''} found
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{puzzles.map((puzzle) => (
<PuzzleCard key={puzzle.id} puzzle={puzzle} />
))}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<span className="text-4xl" aria-hidden="true">🔍</span>
<p className="text-[#E6E6E6] font-medium">No puzzles found</p>
<p className="text-gray-400 text-sm">Try adjusting your filters.</p>
</div>
)}
</>
)}
</div>
</div>
);
};

export default page
export default PuzzleListPage;
97 changes: 97 additions & 0 deletions frontend/components/puzzles/PuzzleCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import React from 'react';
import { useRouter } from 'next/navigation';
import type { Puzzle } from '@/lib/types/puzzles';

interface PuzzleCardProps {
puzzle: Puzzle;
}

const TYPE_CONFIG = {
logic: {
label: 'Logic',
icon: '🧠',
className: 'bg-purple-500/15 text-purple-300 border-purple-500/25',
},
coding: {
label: 'Coding',
icon: '💻',
className: 'bg-blue-500/15 text-blue-300 border-blue-500/25',
},
blockchain: {
label: 'Blockchain',
icon: '⛓️',
className: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/25',
},
} as const;

const DIFFICULTY_CONFIG = {
ALL: { label: 'All', className: 'bg-slate-500/15 text-slate-300 border-slate-500/25', dot: 'bg-slate-400' },
BEGINNER: { label: 'Beginner', className: 'bg-green-500/15 text-green-300 border-green-500/25', dot: 'bg-green-400' },
INTERMEDIATE: { label: 'Intermediate', className: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/25', dot: 'bg-yellow-400' },
ADVANCED: { label: 'Advanced', className: 'bg-orange-500/15 text-orange-300 border-orange-500/25', dot: 'bg-orange-400' },
EXPERT: { label: 'Expert', className: 'bg-red-500/15 text-red-300 border-red-500/25', dot: 'bg-red-500' },
} as const;

const PuzzleCard: React.FC<PuzzleCardProps> = ({ puzzle }) => {
const router = useRouter();

const typeConfig = TYPE_CONFIG[puzzle.type] ?? TYPE_CONFIG.logic;
const diffConfig = DIFFICULTY_CONFIG[puzzle.difficulty] ?? DIFFICULTY_CONFIG.BEGINNER;

return (
<button
onClick={() => router.push(`/puzzles/${puzzle.id}`)}
className="w-full text-left bg-[#0A1628] border border-gray-800 rounded-xl p-5 flex flex-col gap-4 hover:border-gray-600 hover:bg-[#0d1e38] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/50 group"
aria-label={`Open puzzle: ${puzzle.title}`}
>
{/* Badges row */}
<div className="flex items-center gap-2 flex-wrap">
{/* Type badge */}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${typeConfig.className}`}
>
<span aria-hidden="true">{typeConfig.icon}</span>
{typeConfig.label}
</span>

{/* Difficulty badge */}
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${diffConfig.className}`}
>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${diffConfig.dot}`} aria-hidden="true" />
{diffConfig.label}
</span>
</div>

{/* Title */}
<h3 className="text-[#E6E6E6] font-semibold text-base leading-snug group-hover:text-white transition-colors">
{puzzle.title}
</h3>

{/* Description */}
<p className="text-gray-400 text-sm leading-relaxed line-clamp-2">
{puzzle.description}
</p>

{/* Footer: category + time limit */}
<div className="flex items-center justify-between mt-auto pt-2 border-t border-white/5">
{puzzle.categoryId ? (
<span className="text-xs text-blue-400 font-medium px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 truncate max-w-[60%]">
{puzzle.categoryId}
</span>
) : (
<span />
)}
{puzzle.timeLimit && (
<span className="text-xs text-gray-500 flex-shrink-0">
{puzzle.timeLimit} min
</span>
)}
</div>
</button>
);
};

export default PuzzleCard;
2 changes: 1 addition & 1 deletion frontend/hooks/useReducedMotionCheck.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { usePrefersReducedMotion } from 'framer-motion';
import { usePrefersReducedMotion } from '@/lib/animations/hooks';

const useReducedMotionCheck = () => {
const prefersReducedMotion = usePrefersReducedMotion();
Expand Down
2 changes: 1 addition & 1 deletion frontend/lib/animations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useReducedMotion } from 'framer-motion';
* Hook to determine if animations should be disabled based on user preferences.
*/
export const usePrefersReducedMotion = (): boolean => {
return useReducedMotion();
return useReducedMotion() ?? false;
};

/**
Expand Down
19 changes: 19 additions & 0 deletions frontend/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client';

import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
);

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}