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
81 changes: 81 additions & 0 deletions frontend/src/components/common/Button.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';
import { Loader2 } from 'lucide-react';

/**
* Shared button primitive.
*
* Consolidates the gradient / ghost / danger button styles that were inlined
* across the app into one accessible, consistent component.
*
* Props:
* - variant primary | gradient | ghost | danger | subtle (default primary)
* - size sm | md | lg (default md)
* - loading shows a spinner and disables the button
* - icon lucide icon component rendered before the label
* - iconAfter lucide icon component rendered after the label
* - fullWidth stretches to 100% width
* - as render as a different element (e.g. 'a') — defaults to 'button'
* Any other props (onClick, type, disabled, aria-*, href…) are forwarded.
*/
const VARIANTS = {
primary:
'bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-400 hover:to-amber-400 text-white shadow-lg shadow-orange-500/25 hover:shadow-orange-500/40 hover:-translate-y-0.5',
gradient:
'btn-gradient text-white shadow-lg',
purple:
'bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white shadow-[0_4px_14px_rgba(124,58,237,0.35)] hover:shadow-[0_6px_20px_rgba(124,58,237,0.5)] hover:-translate-y-0.5',
ghost:
'bg-slate-100 dark:bg-white/10 hover:bg-slate-200 dark:hover:bg-white/20 text-slate-700 dark:text-white',
subtle:
'bg-transparent hover:bg-slate-100 dark:hover:bg-white/10 text-slate-600 dark:text-white/70',
danger:
'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400 text-white shadow-lg shadow-red-500/25',
};

const SIZES = {
sm: 'px-3 py-1.5 text-xs gap-1.5 rounded-xl',
md: 'px-4 py-2.5 text-sm gap-2 rounded-2xl',
lg: 'px-6 py-3 text-base gap-2.5 rounded-2xl',
};

const ICON_SIZE = { sm: 14, md: 15, lg: 18 };

const Button = ({
variant = 'primary',
size = 'md',
loading = false,
icon: Icon,
iconAfter: IconAfter,
fullWidth = false,
as: Component = 'button',
className = '',
disabled,
children,
...props
}) => {
const isDisabled = disabled || loading;
const iconSize = ICON_SIZE[size] || ICON_SIZE.md;

return (
<Component
type={Component === 'button' ? props.type || 'button' : undefined}
disabled={Component === 'button' ? isDisabled : undefined}
aria-disabled={isDisabled || undefined}
aria-busy={loading || undefined}
className={`inline-flex items-center justify-center font-bold transition-all
${VARIANTS[variant] || VARIANTS.primary} ${SIZES[size] || SIZES.md}
${fullWidth ? 'w-full' : ''}
${isDisabled ? 'opacity-60 cursor-not-allowed pointer-events-none' : ''}
${className}`}
{...props}
>
{loading
? <Loader2 size={iconSize} className="animate-spin" />
: Icon && <Icon size={iconSize} />}
{children}
{!loading && IconAfter && <IconAfter size={iconSize} />}
</Component>
);
};

export default Button;
32 changes: 32 additions & 0 deletions frontend/src/components/common/CountUp.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { useEffect } from 'react';
import { motion, useSpring, useTransform } from 'framer-motion';

/**
* Animates a number from its previous value to the new one with a spring.
* Mirrors the inline pattern used for the shop coin counter so XP / level /
* stat numbers across the app share one consistent count-up feel.
*
* Honours prefers-reduced-motion: jumps straight to the value.
*
* Props:
* - value target number
* - format optional (n) => string formatter (default toLocaleString)
* - className forwarded to the span
*/
const prefersReduced =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;

const CountUp = ({ value = 0, format = (n) => Math.round(n).toLocaleString(), className }) => {
const spring = useSpring(value, { stiffness: 80, damping: 18 });
const display = useTransform(spring, format);

useEffect(() => {
if (prefersReduced) spring.jump(value);
else spring.set(value);
}, [value, spring]);

return <motion.span className={className}>{display}</motion.span>;
};

export default CountUp;
8 changes: 3 additions & 5 deletions frontend/src/components/common/EmptyState.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import Button from './Button';

const EmptyState = ({ icon: Icon, title, description, action }) => (
<div className="glass-card rounded-3xl border-2 border-dashed border-slate-200 dark:border-white/10 p-10 flex flex-col items-center justify-center text-center gap-4">
Expand All @@ -14,12 +15,9 @@ const EmptyState = ({ icon: Icon, title, description, action }) => (
)}
</div>
{action && (
<button
onClick={action.onClick}
className="mt-2 px-5 py-2.5 bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-400 hover:to-purple-400 text-white text-sm font-bold rounded-xl transition-all shadow-[0_0_15px_rgba(99,102,241,0.25)]"
>
<Button onClick={action.onClick} className="mt-2">
{action.label}
</button>
</Button>
)}
</div>
);
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/common/Pagination.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,29 @@ import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';

const Pagination = ({ page, pages, onPage }) => (
<div className="flex items-center gap-2 justify-end mt-4">
<nav className="flex items-center gap-2 justify-end mt-4" aria-label="Pagination">
<button
type="button"
onClick={() => onPage(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 hover:bg-slate-100 dark:hover:bg-white/5 transition"
>
<ChevronLeft size={14} />
</button>
<span className="text-xs text-slate-500 dark:text-white/40 font-medium">
<span className="text-xs text-slate-500 dark:text-white/40 font-medium" aria-current="page">
Page {page} / {pages}
</span>
<button
type="button"
onClick={() => onPage(page + 1)}
disabled={page >= pages}
aria-label="Next page"
className="w-8 h-8 rounded-lg border border-slate-200/60 dark:border-white/10 flex items-center justify-center disabled:opacity-30 hover:bg-slate-100 dark:hover:bg-white/5 transition"
>
<ChevronRight size={14} />
</button>
</div>
</nav>
);

export default Pagination;
30 changes: 30 additions & 0 deletions frontend/src/components/common/StatCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import { motion } from 'framer-motion';

/**
* Compact stat tile used on the dashboard and elsewhere.
*
* Props:
* - icon lucide icon component
* - value the number/string to highlight
* - label small uppercase caption
* - color tailwind bg-* class used for the subtle hover wash
* - glow className applied to the icon (color + drop-shadow glow)
*/
const StatCard = ({ icon: Icon, value, label, color = 'bg-orange-500', glow = '' }) => (
<motion.div
whileHover={{ y: -3, scale: 1.02 }}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
className="glass-card rounded-2xl p-4 flex flex-col items-center justify-center text-center gap-1 shadow-sm relative overflow-hidden group cursor-default"
>
<div
className={`absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ${color} rounded-2xl`}
style={{ opacity: 0.04 }}
/>
{Icon && <Icon size={20} className={glow} />}
<span className="text-2xl font-black text-slate-900 dark:text-white leading-none">{value}</span>
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-white/40">{label}</span>
</motion.div>
);

export default StatCard;
4 changes: 3 additions & 1 deletion frontend/src/components/dashboard/CourseSidebarItem.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from 'react';
import { motion } from 'framer-motion';
import { ChevronRight, PlayCircle } from 'lucide-react';
import { clickableProps } from '../../utils/a11y';

const CourseSidebarItem = ({ course, isSelected, onClick }) => {
return (
<motion.div
layout
onClick={onClick}
{...clickableProps(onClick, `Select course ${course.title}`)}
aria-pressed={isSelected}
className={`cursor-pointer group relative p-3 rounded-xl transition-all duration-300 ${isSelected
? 'bg-slate-100 dark:bg-white/10 border border-slate-200 dark:border-indigo-500/30 shadow-lg dark:shadow-indigo-500/10'
: 'hover:bg-slate-200/50 dark:hover:bg-white/5 border border-transparent'
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/dashboard/RoadmapView.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { clickableProps } from '../../utils/a11y';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import useCourseStore from '../../store/courseStore';
Expand Down Expand Up @@ -354,7 +355,8 @@ const RoadmapView = () => {
{/* ── Card column ── */}
<div className="flex-1 min-w-0">
<motion.div
onClick={() => handleToggleExpand(nodeId)}
{...clickableProps(() => handleToggleExpand(nodeId), `${isExpanded ? 'Collapse' : 'Expand'} ${node.title}`)}
aria-expanded={isExpanded}
whileHover={{ scale: 1.01, y: -1, transition: { duration: 0.15 } }}
whileTap={{ scale: 0.985 }}
className={`
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/index.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Design system barrel — used by design-sync converter
export { default as Button } from './common/Button.jsx';
export { default as StatCard } from './common/StatCard.jsx';
export { default as CountUp } from './common/CountUp.jsx';
export { default as EmptyState } from './common/EmptyState.jsx';
export { default as LoadingSkeleton } from './common/LoadingSkeleton.jsx';
export { default as ToastManager } from './common/ToastManager.jsx';
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/layout/StudentLayout.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { clickableProps } from '../../utils/a11y';
import { Settings, LogOut, Menu, Map, BookOpen, ChevronRight, ChevronDown, X, Flame, Zap, User, ShoppingBag, Trophy } from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import { io } from 'socket.io-client';
Expand Down Expand Up @@ -256,7 +257,7 @@ const SidebarContent = ({ location, navigate, courses, selectedCourseId, setSele

{/* Player Card with gradient border */}
<div
onClick={() => { navigate('/profile'); setIsSidebarOpen(false); }}
{...clickableProps(() => { navigate('/profile'); setIsSidebarOpen(false); }, 'Open my profile')}
className="cursor-pointer group/card mb-3"
>
<GradientBorderCard
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/layout/StudentNotificationBell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ const StudentNotificationBell = ({ dropUp = false }) => {
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={handleOpen}
className="relative p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-white/10 text-slate-500 dark:text-white/50 hover:text-slate-800 dark:hover:text-white transition-colors"
aria-label="Notifications"
aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'}
aria-haspopup="true"
aria-expanded={open}
>
<Bell size={20} />
{unread > 0 && (
Expand All @@ -61,15 +64,19 @@ const StudentNotificationBell = ({ dropUp = false }) => {
{notifications.length > 0 && (
<>
<button
type="button"
onClick={markAllRead}
title="Mark all read"
aria-label="Mark all notifications as read"
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/10 text-slate-400 hover:text-slate-600 dark:hover:text-white transition-colors"
>
<CheckCheck size={14} />
</button>
<button
type="button"
onClick={clearAll}
title="Clear all"
aria-label="Clear all notifications"
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/10 text-slate-400 hover:text-red-500 transition-colors"
>
<Trash2 size={14} />
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ body {
radial-gradient(ellipse 40% 40% at 50% 50%, rgba(196,97,61,0.04) 0%, transparent 70%);
}

/* ── Global Keyboard Focus ───────────────────────────── */
/* A single, consistent focus ring for every interactive element so keyboard
navigation is always visible. Uses :focus-visible so it never shows on
mouse/touch interaction. Components can opt out with `focus-visible:ring-0`. */
:where(a, button, [role="button"], input, select, textarea, summary, [tabindex]):focus-visible {
outline: none;
border-radius: max(0.5rem, 4px);
box-shadow: 0 0 0 2px #F8F5F1, 0 0 0 4px var(--color-studylabs-orange);
}
.dark :where(a, button, [role="button"], input, select, textarea, summary, [tabindex]):focus-visible {
box-shadow: 0 0 0 2px #100C09, 0 0 0 4px var(--color-studylabs-orange);
}
/* Inputs already carry their own ring utilities in places — keep their radius */
:where(input, select, textarea):focus-visible { border-radius: inherit; }

/* ── Scrollbar ───────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/ClassRoster.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Users, Zap, Flame, CheckSquare, Square, Mail, SlidersHorizontal, Trendi
import useCourseStore from '../store/courseStore';
import useEnrollmentStore from '../store/enrollmentStore';
import api from '../utils/api';
import { clickableProps } from '../utils/a11y';

const MS_PER_DAY = 1000 * 60 * 60 * 24;
const daysSince = (date) => date ? Math.floor((Date.now() - new Date(date)) / MS_PER_DAY) : null;
Expand Down Expand Up @@ -257,7 +258,8 @@ const ClassRoster = () => {
return (
<div
key={student._id}
onClick={() => toggleSelect(student._id)}
{...clickableProps(() => toggleSelect(student._id), `Select ${student.name || 'student'}`)}
aria-pressed={isChecked}
className={`relative bg-white dark:bg-white/5 border-2 rounded-3xl p-5 cursor-pointer transition-all duration-200 hover:shadow-lg group ${
isChecked
? 'border-purple-500 shadow-[0_0_0_3px_rgba(124,58,237,0.15)]'
Expand Down
Loading
Loading