diff --git a/frontend/src/components/common/Button.jsx b/frontend/src/components/common/Button.jsx new file mode 100644 index 0000000..58a22ec --- /dev/null +++ b/frontend/src/components/common/Button.jsx @@ -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 ( + + {loading + ? + : Icon && } + {children} + {!loading && IconAfter && } + + ); +}; + +export default Button; diff --git a/frontend/src/components/common/CountUp.jsx b/frontend/src/components/common/CountUp.jsx new file mode 100644 index 0000000..a404b8f --- /dev/null +++ b/frontend/src/components/common/CountUp.jsx @@ -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 {display}; +}; + +export default CountUp; diff --git a/frontend/src/components/common/EmptyState.jsx b/frontend/src/components/common/EmptyState.jsx index ae17437..b457107 100644 --- a/frontend/src/components/common/EmptyState.jsx +++ b/frontend/src/components/common/EmptyState.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import Button from './Button'; const EmptyState = ({ icon: Icon, title, description, action }) => (
@@ -14,12 +15,9 @@ const EmptyState = ({ icon: Icon, title, description, action }) => ( )}
{action && ( - + )} ); diff --git a/frontend/src/components/common/Pagination.jsx b/frontend/src/components/common/Pagination.jsx index 89836ef..dea9647 100644 --- a/frontend/src/components/common/Pagination.jsx +++ b/frontend/src/components/common/Pagination.jsx @@ -2,25 +2,29 @@ import React from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; const Pagination = ({ page, pages, onPage }) => ( -
+
+ ); export default Pagination; diff --git a/frontend/src/components/common/StatCard.jsx b/frontend/src/components/common/StatCard.jsx new file mode 100644 index 0000000..9d35f57 --- /dev/null +++ b/frontend/src/components/common/StatCard.jsx @@ -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 = '' }) => ( + +
+ {Icon && } + {value} + {label} + +); + +export default StatCard; diff --git a/frontend/src/components/dashboard/CourseSidebarItem.jsx b/frontend/src/components/dashboard/CourseSidebarItem.jsx index de18f81..24d3ee1 100644 --- a/frontend/src/components/dashboard/CourseSidebarItem.jsx +++ b/frontend/src/components/dashboard/CourseSidebarItem.jsx @@ -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 ( { {/* ── Card column ── */}
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={` diff --git a/frontend/src/components/index.jsx b/frontend/src/components/index.jsx index 9120345..d3dcf46 100644 --- a/frontend/src/components/index.jsx +++ b/frontend/src/components/index.jsx @@ -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'; diff --git a/frontend/src/components/layout/StudentLayout.jsx b/frontend/src/components/layout/StudentLayout.jsx index 39ec06f..f86d981 100644 --- a/frontend/src/components/layout/StudentLayout.jsx +++ b/frontend/src/components/layout/StudentLayout.jsx @@ -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'; @@ -256,7 +257,7 @@ const SidebarContent = ({ location, navigate, courses, selectedCourseId, setSele {/* Player Card with gradient border */}
{ navigate('/profile'); setIsSidebarOpen(false); }} + {...clickableProps(() => { navigate('/profile'); setIsSidebarOpen(false); }, 'Open my profile')} className="cursor-pointer group/card mb-3" > { return (
+ {/* ── Stats Strip ── */} @@ -284,9 +271,12 @@ const Dashboard = () => {
)} diff --git a/frontend/src/pages/ManagedCourses.jsx b/frontend/src/pages/ManagedCourses.jsx index 1c239a2..88a8d7f 100644 --- a/frontend/src/pages/ManagedCourses.jsx +++ b/frontend/src/pages/ManagedCourses.jsx @@ -4,10 +4,11 @@ import InstructorLayout from '../components/layout/InstructorLayout'; import useCourseStore from '../store/courseStore'; import useEnrollmentStore from '../store/enrollmentStore'; import { getDeptStyle } from '../utils/departmentStyles'; +import Button from '../components/common/Button'; import { Search, BookOpen, Users, ArrowRight, Loader2, Trash2, Zap, Star, Layers, Check, X, Clock, AlertCircle, - UserCheck, ChevronDown, Pencil, Save + UserCheck, ChevronDown, Pencil, Save, Plus } from 'lucide-react'; const COURSE_AVATARS = [ @@ -123,17 +124,10 @@ const EditCourseModal = ({ course, onClose, onSave }) => { {/* Footer */}
- - + +
@@ -390,12 +384,14 @@ const ManagedCourses = () => { className="w-full pl-11 pr-4 py-3 rounded-2xl border border-slate-200 dark:border-white/10 bg-white/70 dark:bg-black/30 backdrop-blur-md text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-white/40 focus:border-[#D97757] focus:bg-white dark:focus:bg-slate-900/60 focus:outline-none transition-all shadow-sm focus:shadow-md dark:shadow-inner" />
- + New Course + )} diff --git a/frontend/src/pages/StudyShop.jsx b/frontend/src/pages/StudyShop.jsx index 4d1957a..eca7b2c 100644 --- a/frontend/src/pages/StudyShop.jsx +++ b/frontend/src/pages/StudyShop.jsx @@ -7,6 +7,7 @@ import { Coins, ShoppingBag, Zap, HelpCircle, Clock, Star } from 'lucide-react'; import { motion, useSpring, useTransform, AnimatePresence } from 'framer-motion'; import ConfettiEffect from '../components/gamification/ConfettiEffect'; import ItemPreviewModal from '../components/shop/ItemPreviewModal'; +import { clickableProps } from '../utils/a11y'; // ── Sub-components ──────────────────────────────────────────────────────── @@ -36,7 +37,7 @@ const ItemCard = ({ item, category, isOwned, ownedCount, onPreview }) => { initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} - onClick={() => onPreview(item, category)} + {...clickableProps(() => onPreview(item, category), `Preview ${item.name}`)} className="group relative bg-white dark:bg-white/[0.03] border border-slate-200/60 dark:border-white/[0.06] rounded-2xl p-4 flex items-center justify-between gap-4 cursor-pointer hover:border-indigo-400/40 dark:hover:border-indigo-500/30 transition-all hover:shadow-lg" style={{ '--accent': r.color }} > @@ -117,7 +118,7 @@ const FeaturedBanner = ({ item, coins, isOwned, onPreview }) => { animate={{ opacity: 1, y: 0 }} className="relative rounded-3xl overflow-hidden border cursor-pointer group" style={{ borderColor: `${r.color}33`, background: `linear-gradient(135deg, ${r.color}11 0%, transparent 60%)` }} - onClick={() => onPreview(item, item.category)} + {...clickableProps(() => onPreview(item, item.category), `Preview featured item ${item.name}`)} >
diff --git a/frontend/src/utils/a11y.js b/frontend/src/utils/a11y.js new file mode 100644 index 0000000..868e1bd --- /dev/null +++ b/frontend/src/utils/a11y.js @@ -0,0 +1,23 @@ +/** + * Accessibility helpers for non-native interactive elements. + * + * When a `
`/`` must stay a div (e.g. for layout animations) + * but behaves like a button, spread `clickableProps()` onto it so it is + * reachable and operable by keyboard and announced correctly to screen readers. + * + * + * + * Adds: role="button", tabIndex, and Enter/Space activation. + */ +export const clickableProps = (onClick, label) => ({ + role: 'button', + tabIndex: 0, + 'aria-label': label, + onClick, + onKeyDown: (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(e); + } + }, +});