diff --git a/jportal/package-lock.json b/jportal/package-lock.json index 3526f92..4e8ba6c 100644 --- a/jportal/package-lock.json +++ b/jportal/package-lock.json @@ -37,6 +37,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "web-haptics": "^0.0.6", "zod": "^3.23.8", "zustand": "^5.0.8" }, @@ -11849,6 +11850,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-haptics": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz", + "integrity": "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/jportal/package.json b/jportal/package.json index e95a986..3067694 100644 --- a/jportal/package.json +++ b/jportal/package.json @@ -42,6 +42,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "web-haptics": "^0.0.6", "zod": "^3.23.8", "zustand": "^5.0.8" }, diff --git a/jportal/src/components/AttendanceCard.jsx b/jportal/src/components/AttendanceCard.jsx index 974cb6d..9411080 100644 --- a/jportal/src/components/AttendanceCard.jsx +++ b/jportal/src/components/AttendanceCard.jsx @@ -4,6 +4,7 @@ import { Card } from "@/components/ui/card"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Calendar } from "@/components/ui/calendar"; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; +import { useHaptics } from "@/hooks/useHaptics"; const AttendanceCard = ({ subject, @@ -21,7 +22,9 @@ const AttendanceCard = ({ const [isLoading, setIsLoading] = useState(false); const [selectedDate, setSelectedDate] = useState(null); + const haptics = useHaptics(); const handleClick = async () => { + haptics.tap(); setSelectedSubject(subject); if (!subjectAttendanceData[subject.name]) { setIsLoading(true); @@ -152,9 +155,12 @@ const AttendanceCard = ({ { - setSelectedSubject(null); - setSelectedDate(null); + onOpenChange={(open) => { + if (!open) { + haptics.tap(); + setSelectedSubject(null); + setSelectedDate(null); + } }} > @@ -268,7 +274,11 @@ const AttendanceCard = ({ }, }} selected={selectedDate} - onSelect={(date) => setSelectedDate(date)} + onSelect={(date) => { + if (date) haptics.selection(); + setSelectedDate(date); + }} + onMonthChange={() => haptics.selection()} className={`pb-2 text-foreground ${isLoading ? "animate-pulse" : ""} w-full shrink-0 max-w-full`} classNames={{ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", diff --git a/jportal/src/components/Header.jsx b/jportal/src/components/Header.jsx index 6b160ea..b71a061 100644 --- a/jportal/src/components/Header.jsx +++ b/jportal/src/components/Header.jsx @@ -5,6 +5,7 @@ import LogoutIcon from "@/../public/icons/logout.svg?react"; import { Link } from "react-router-dom"; import { ChartNoAxesCombined } from "lucide-react"; import { useState, useEffect, useRef } from "react"; +import { useHaptics } from "@/hooks/useHaptics"; const FairyLights = () => { const [textMetrics, setTextMetrics] = useState(null); @@ -216,8 +217,10 @@ const FairyLights = () => { const Header = ({ setIsAuthenticated, setIsDemoMode }) => { const navigate = useNavigate(); + const haptics = useHaptics(); const handleLogout = () => { + haptics.tap(); localStorage.removeItem("username"); localStorage.removeItem("password"); localStorage.removeItem("attendanceData"); diff --git a/jportal/src/components/InteractiveGPAChart.jsx b/jportal/src/components/InteractiveGPAChart.jsx index 8729199..53e8ab8 100644 --- a/jportal/src/components/InteractiveGPAChart.jsx +++ b/jportal/src/components/InteractiveGPAChart.jsx @@ -6,8 +6,10 @@ import { CHART_CONFIG, DRAG_CONFIG } from "@/utils/chartConstants"; import DraggableDot from "./DraggableDot"; import GPAChartTooltip from "./GPAChartTooltip"; import { Card } from "@/components/ui/card"; +import { useHaptics } from "@/hooks/useHaptics"; export default function InteractiveGPAChart({ semesterData, onDataChange }) { + const haptics = useHaptics(); const [chartData, setChartData] = useState(semesterData); const [dragState, setDragState] = useState({ isDragging: false, @@ -29,6 +31,7 @@ export default function InteractiveGPAChart({ semesterData, onDataChange }) { // Don't allow dragging during intro animation if (isAnimating) return; + haptics.tap(); const semesterValue = chartData[index].sgpa; setDragState({ isDragging: true, @@ -61,6 +64,10 @@ export default function InteractiveGPAChart({ semesterData, onDataChange }) { // Round to specified precision newValue = Math.round(newValue / DRAG_CONFIG.precision) * DRAG_CONFIG.precision; + if (newValue !== chartData[dragState.draggedIndex].sgpa) { + haptics.selection(); + } + // Recalculate CGPA based on new SGPA const updatedData = recalculateCGPA(chartData, dragState.draggedIndex, newValue); setChartData(updatedData); @@ -72,6 +79,7 @@ export default function InteractiveGPAChart({ semesterData, onDataChange }) { }; const handleDragEnd = () => { + haptics.tap(); setDragState({ isDragging: false, draggedIndex: null, @@ -85,6 +93,7 @@ export default function InteractiveGPAChart({ semesterData, onDataChange }) { // Don't allow dragging during intro animation if (isAnimating) return; + haptics.tap(); setCardDragState({ isDragging: true, draggedIndex: index, @@ -106,6 +115,10 @@ export default function InteractiveGPAChart({ semesterData, onDataChange }) { // Round to specified precision newValue = Math.round(newValue / DRAG_CONFIG.precision) * DRAG_CONFIG.precision; + if (newValue !== chartData[index].sgpa) { + haptics.selection(); + } + // Recalculate CGPA based on new SGPA const updatedData = recalculateCGPA(chartData, index, newValue); setChartData(updatedData); @@ -123,6 +136,7 @@ export default function InteractiveGPAChart({ semesterData, onDataChange }) { }; const handleCardDragEnd = () => { + haptics.tap(); setCardDragState({ isDragging: false, draggedIndex: null, @@ -176,6 +190,7 @@ export default function InteractiveGPAChart({ semesterData, onDataChange }) { // Animation complete - reset to original values setChartData(originalDataRef.current); setIsAnimating(false); + haptics.success(); if (onDataChange) { onDataChange(originalDataRef.current); } diff --git a/jportal/src/components/Login.jsx b/jportal/src/components/Login.jsx index 10e4c1c..886d79d 100644 --- a/jportal/src/components/Login.jsx +++ b/jportal/src/components/Login.jsx @@ -10,6 +10,7 @@ import { toast } from "sonner"; import { Eye, EyeOff } from "lucide-react"; import { LoginError } from "https://cdn.jsdelivr.net/npm/jsjiit@0.0.23/dist/jsjiit.esm.js"; import PublicHeader from "./PublicHeader"; +import { useHaptics } from "@/hooks/useHaptics"; // Define the form schema const formSchema = z.object({ @@ -27,6 +28,7 @@ export default function Login({ onLoginSuccess, onDemoLogin, w }) { isLoading: false, credentials: null, }); + const haptics = useHaptics(); // Initialize form const form = useForm({ @@ -39,6 +41,7 @@ export default function Login({ onLoginSuccess, onDemoLogin, w }) { // Handle demo login const handleDemoLogin = () => { + haptics.success(); onDemoLogin(); }; @@ -55,6 +58,7 @@ export default function Login({ onLoginSuccess, onDemoLogin, w }) { localStorage.setItem("password", loginStatus.credentials.password); console.log("Login successful"); + haptics.success(); setLoginStatus((prev) => ({ ...prev, isLoading: false, @@ -67,11 +71,14 @@ export default function Login({ onLoginSuccess, onDemoLogin, w }) { error.message.includes("JIIT Web Portal server is temporarily unavailable") ) { console.error("JIIT Web Portal server is temporarily unavailable"); + haptics.error(); toast.error("JIIT Web Portal server is temporarily unavailable. Please try again later."); } else if (error instanceof LoginError && error.message.includes("Failed to fetch")) { + haptics.error(); toast.error("Please check your internet connection. If connected, JIIT Web Portal server is unavailable."); } else { console.error("Login failed:", error); + haptics.error(); toast.error("Login failed. Please check your credentials."); } setLoginStatus((prev) => ({ diff --git a/jportal/src/components/Navbar.jsx b/jportal/src/components/Navbar.jsx index 3da99f2..b93ae0d 100644 --- a/jportal/src/components/Navbar.jsx +++ b/jportal/src/components/Navbar.jsx @@ -4,8 +4,11 @@ import GradesIcon from "@/../public/icons/grades.svg?react"; import ExamsIcon from "@/../public/icons/exams.svg?react"; import SubjectsIcon from "@/../public/icons/subjects1.svg?react"; import ProfileIcon from "@/../public/icons/profile.svg?react"; +import { useHaptics } from "@/hooks/useHaptics"; function Navbar() { + const haptics = useHaptics(); + const navItems = [ { name: "ATTENDANCE", path: "/attendance", IconComponent: AttendanceIcon }, { name: " GRADES ", path: "/grades", IconComponent: GradesIcon }, @@ -20,6 +23,7 @@ function Navbar() { haptics.tap()} className={({ isActive }) => ` flex-1 text-md text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground text-clip overflow-hidden whitespace-nowrap ${isActive ? "opacity-100" : "opacity-70"} diff --git a/jportal/src/components/theme-selector.tsx b/jportal/src/components/theme-selector.tsx index ef8709c..e00183a 100644 --- a/jportal/src/components/theme-selector.tsx +++ b/jportal/src/components/theme-selector.tsx @@ -2,6 +2,7 @@ import { defaultPresets } from "../utils/theme-presets"; import { useThemeStore } from "../stores/theme-store"; import { Button } from "./ui/button"; import { cn } from "../lib/utils"; +import { useHaptics } from "../hooks/useHaptics"; interface ThemeSelectorProps { className?: string; @@ -9,11 +10,13 @@ interface ThemeSelectorProps { export function ThemeSelector({ className }: ThemeSelectorProps) { const { themeState, setThemeState } = useThemeStore(); + const haptics = useHaptics(); const handleThemeSelect = (presetKey: string) => { const preset = defaultPresets[presetKey]; if (!preset) return; + haptics.success(); setThemeState({ ...themeState, preset: presetKey, diff --git a/jportal/src/components/ui/button.jsx b/jportal/src/components/ui/button.jsx index 22b5a72..841f345 100644 --- a/jportal/src/components/ui/button.jsx +++ b/jportal/src/components/ui/button.jsx @@ -3,6 +3,7 @@ import { Slot } from "@radix-ui/react-slot" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" +import { useHaptics } from "@/hooks/useHaptics" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", @@ -35,11 +36,22 @@ const buttonVariants = cva( const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" + const haptics = useHaptics(); + + const handleClick = (e) => { + // Only trigger haptic if button is not disabled + if (!props.disabled) { + haptics.tap(); + } + props.onClick?.(e); + }; + return ( () + {...props} + onClick={handleClick} />) ); }) Button.displayName = "Button" diff --git a/jportal/src/components/ui/dialog.jsx b/jportal/src/components/ui/dialog.jsx index eeae1dc..53ff9b0 100644 --- a/jportal/src/components/ui/dialog.jsx +++ b/jportal/src/components/ui/dialog.jsx @@ -3,6 +3,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" import { cn } from "@/lib/utils" +import { useHaptics } from "@/hooks/useHaptics" const Dialog = DialogPrimitive.Root @@ -12,15 +13,24 @@ const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close -const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( - -)) +const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => { + const haptics = useHaptics(); + + // Trigger haptic when overlay appears (dialog opens) + React.useEffect(() => { + haptics.tap(); + }, []); + + return ( + + ); +}) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( diff --git a/jportal/src/components/ui/select.jsx b/jportal/src/components/ui/select.jsx index 23d0b4c..174991f 100644 --- a/jportal/src/components/ui/select.jsx +++ b/jportal/src/components/ui/select.jsx @@ -3,6 +3,7 @@ import * as SelectPrimitive from "@radix-ui/react-select"; import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useHaptics } from "@/hooks/useHaptics"; const Select = SelectPrimitive.Root; @@ -82,24 +83,29 @@ const SelectLabel = React.forwardRef(({ className, ...props }, ref) => ( )); SelectLabel.displayName = SelectPrimitive.Label.displayName; -const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => ( - - - - - - +const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => { + const haptics = useHaptics(); - {children} - -)); + return ( + haptics.selection()} + {...props} + > + + + + + + + {children} + + ); +}); SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => ( diff --git a/jportal/src/components/ui/tabs.jsx b/jportal/src/components/ui/tabs.jsx index a011472..9b76a6d 100644 --- a/jportal/src/components/ui/tabs.jsx +++ b/jportal/src/components/ui/tabs.jsx @@ -2,8 +2,120 @@ import * as React from "react" import * as TabsPrimitive from "@radix-ui/react-tabs" import { cn } from "@/lib/utils" +import { useHaptics } from "@/hooks/useHaptics" -const Tabs = TabsPrimitive.Root +const SWIPE_MIN_DISTANCE = 56 +const SWIPE_AXIS_RATIO = 1.25 +const SWIPE_MAX_DURATION = 650 + +function isSwipeExcludedTarget(target) { + if (!(target instanceof HTMLElement)) return true + if (!target.closest("[role='tabpanel']")) return true + if (target.closest("[data-no-tab-swipe]")) return true + if (target.closest("input, textarea, select, option, button, a, [contenteditable='true']")) return true + return false +} + +const Tabs = React.forwardRef(({ swipeable = true, onTouchStart, onTouchMove, onTouchEnd, ...props }, ref) => { + const rootRef = React.useRef(null) + const haptics = useHaptics() + const gestureRef = React.useRef({ + active: false, + startX: 0, + startY: 0, + startTime: 0, + lastX: 0, + lastTime: 0, + blocked: false, + }) + + const setRefs = (node) => { + rootRef.current = node + if (typeof ref === "function") ref(node) + else if (ref) ref.current = node + } + + const switchTab = (direction) => { + const root = rootRef.current + if (!root) return + + const tabs = Array.from(root.querySelectorAll("[role='tab'][data-tab-value]")) + .filter((tab) => !tab.hasAttribute("disabled") && tab.getAttribute("aria-disabled") !== "true") + + if (tabs.length <= 1) return + + const activeIndex = tabs.findIndex((tab) => tab.getAttribute("data-state") === "active") + if (activeIndex === -1) return + + const nextIndex = direction === "next" ? activeIndex + 1 : activeIndex - 1 + if (nextIndex < 0 || nextIndex >= tabs.length) return + + const targetTab = tabs[nextIndex] + haptics.selection() + targetTab.focus({ preventScroll: true }) + targetTab.click() + } + + const handleTouchStart = (event) => { + onTouchStart?.(event) + if (!swipeable || event.touches.length !== 1) return + + const touch = event.touches[0] + const blocked = isSwipeExcludedTarget(event.target) + gestureRef.current = { + active: true, + startX: touch.clientX, + startY: touch.clientY, + startTime: Date.now(), + lastX: touch.clientX, + lastTime: Date.now(), + blocked, + } + } + + const handleTouchMove = (event) => { + onTouchMove?.(event) + if (!gestureRef.current.active || gestureRef.current.blocked || event.touches.length !== 1) return + + const touch = event.touches[0] + gestureRef.current.lastX = touch.clientX + gestureRef.current.lastTime = Date.now() + } + + const handleTouchEnd = (event) => { + onTouchEnd?.(event) + const gesture = gestureRef.current + gestureRef.current.active = false + + if (!swipeable || gesture.blocked) return + + const touch = event.changedTouches[0] + if (!touch) return + + const deltaX = touch.clientX - gesture.startX + const deltaY = touch.clientY - gesture.startY + const absX = Math.abs(deltaX) + const absY = Math.abs(deltaY) + const duration = Date.now() - gesture.startTime + + if (absX < SWIPE_MIN_DISTANCE) return + if (absX < absY * SWIPE_AXIS_RATIO) return + if (duration > SWIPE_MAX_DURATION && absX < SWIPE_MIN_DISTANCE * 1.5) return + + switchTab(deltaX < 0 ? "next" : "prev") + } + + return ( + + ) +}) +Tabs.displayName = TabsPrimitive.Root.displayName const TabsList = React.forwardRef(({ className, ...props }, ref) => ( ( )) TabsList.displayName = TabsPrimitive.List.displayName -const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => ( - -)) +const TabsTrigger = React.forwardRef(({ className, onMouseDown, value, ...props }, ref) => { + const haptics = useHaptics() + + return ( + { + haptics.selection() + onMouseDown?.(event) + }} + value={value} + {...props} /> + ) +}) TabsTrigger.displayName = TabsPrimitive.Trigger.displayName const TabsContent = React.forwardRef(({ className, ...props }, ref) => (