Skip to content
Open
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
6 changes: 3 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 10 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ThemeProvider } from './components/ThemeContext'
import { ToastProvider } from './components/ToastContext'
import { I18nProvider } from './components/I18nContext'
import { AuthProvider } from './components/AuthContext'
import { CursorProvider } from './context/CursorContext'
import { routes } from './routes'

/** Authenticated app chrome. Rendered only inside ProtectedRoute, so no page
Expand Down Expand Up @@ -63,13 +64,15 @@ export default function App() {
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<ErrorBoundary>
<AuthProvider>
<Router>
<AppRoutes />
</Router>
</AuthProvider>
</ErrorBoundary>
<CursorProvider>
<ErrorBoundary>
<AuthProvider>
<Router>
<AppRoutes />
</Router>
</AuthProvider>
</ErrorBoundary>
</CursorProvider>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { NavLink, useLocation } from 'react-router-dom'
import Sidebar from './Sidebar'
import Background from './Background'
import GlobalSearch from './GlobalSearch'
import { CustomCursor } from './CustomCursor'
import { CursorSelector } from './CursorSelector'
import { useShortcuts } from '../hooks/useShortcuts'
import { SidebarProvider, useSidebar } from '../context/SidebarContext'
import { routes } from '../routes'
Expand Down Expand Up @@ -90,6 +92,7 @@ function AppShellInner({ children }: AppShellProps) {
return (
<>
<Background state="idle" />
<CustomCursor />
<div className="flex bg-charcoal-dark min-h-screen">
<Sidebar />
<div className="lg:hidden fixed inset-x-0 top-0 z-[60] bg-[var(--bg-secondary)] border-b border-accent-silver/10 h-14 px-4 flex items-center gap-3">
Expand All @@ -106,6 +109,7 @@ function AppShellInner({ children }: AppShellProps) {
</span>
</button>
<GlobalSearch className="flex-1 min-w-0" />
<CursorSelector />
</div>

{mobileMenuOpen && (
Expand Down Expand Up @@ -150,8 +154,9 @@ function AppShellInner({ children }: AppShellProps) {
className="flex-1 flex flex-col min-w-0 transition-all duration-300 ease-in-out ml-0 lg:ml-[var(--sidebar-width)]"
style={{ '--sidebar-width': `${desktopSidebarWidth}px` } as React.CSSProperties}
>
<div className="hidden lg:flex items-center h-14 px-6 border-b border-accent-silver/10 bg-[var(--bg-secondary)]">
<div className="hidden lg:flex items-center justify-between h-14 px-6 border-b border-accent-silver/10 bg-[var(--bg-secondary)]">
<GlobalSearch className="w-full max-w-md" />
<CursorSelector />
</div>
<main
className="flex-1 overflow-auto pt-14 lg:pt-0 pb-20 lg:pb-0"
Expand Down
73 changes: 73 additions & 0 deletions frontend/src/components/CursorSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useState, useRef, useEffect } from 'react'
import { useCursor, CursorStyle } from '../context/CursorContext'

const OPTIONS: { label: string; value: CursorStyle; icon: string }[] = [
{ label: 'Default', value: 'default', icon: 'near_me' },
{ label: 'Glow', value: 'glow', icon: 'light_mode' },
{ label: 'Trail', value: 'trail', icon: 'blur_on' },
{ label: 'Sparkle', value: 'sparkle', icon: 'auto_awesome' },
{ label: 'Orbit', value: 'orbit', icon: 'sync' },
{ label: 'Chess', value: 'chess', icon: 'sports_esports' },
]

export const CursorSelector: React.FC = () => {
const { cursorStyle, setCursorStyle } = useCursor()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])

const activeOption = OPTIONS.find((opt) => opt.value === cursorStyle) || OPTIONS[0]

return (
<div className="relative inline-block text-left" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="h-9 px-3 flex items-center gap-2 rounded border border-accent-silver/20 bg-charcoal-dark text-silver-bright hover:bg-charcoal-light transition-colors text-xs font-medium"
aria-label="Select cursor style"
aria-expanded={isOpen}
>
<span className="material-symbols-outlined text-[18px]">
{activeOption.icon}
</span>
<span className="hidden sm:inline">{activeOption.label}</span>
<span className="material-symbols-outlined text-[16px] transition-transform duration-200">
{isOpen ? 'expand_less' : 'expand_more'}
</span>
</button>

{isOpen && (
<div className="absolute right-0 mt-2 w-40 origin-top-right rounded border border-accent-silver/20 bg-[var(--bg-secondary)] py-1 shadow-lg z-[70]">
{OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => {
setCursorStyle(opt.value)
setIsOpen(false)
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors ${
cursorStyle === opt.value
? 'bg-rag-red/10 text-silver-bright font-bold'
: 'text-silver/80 hover:bg-charcoal-light/50 hover:text-silver-bright'
}`}
>
<span className="material-symbols-outlined text-[16px]">
{opt.icon}
</span>
<span>{opt.label}</span>
</button>
))}
</div>
)}
</div>
)
}
73 changes: 73 additions & 0 deletions frontend/src/components/CustomCursor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react'
import { useCursor } from '../context/CursorContext'

export const CustomCursor: React.FC = () => {
const { cursorStyle } = useCursor()
const [position, setPosition] = useState({ x: -100, y: -100 })
const [isPointer, setIsPointer] = useState(false)

useEffect(() => {
if (cursorStyle === 'default') return

const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY })

const target = e.target as HTMLElement | null
if (target) {
const computedCursor = window.getComputedStyle(target).cursor
setIsPointer(
computedCursor === 'pointer' ||
target.tagName === 'BUTTON' ||
target.tagName === 'A' ||
target.closest('button') !== null ||
target.closest('a') !== null
)
}
}

window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [cursorStyle])

if (cursorStyle === 'default') return null

return (
<div
className="pointer-events-none fixed inset-0 z-[9999] overflow-hidden"
aria-hidden="true"
>
<div
className={`fixed top-0 left-0 -translate-x-1/2 -translate-y-1/2 transition-transform duration-75 ease-out ${
isPointer ? 'scale-125' : 'scale-100'
}`}
style={{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
}}
>
{cursorStyle === 'glow' && (
<div className="h-8 w-8 rounded-full bg-amber-400/40 blur-md shadow-[0_0_15px_rgba(251,191,36,0.8)]" />
)}

{cursorStyle === 'trail' && (
<div className="h-6 w-6 rounded-full border-2 border-rag-red bg-rag-red/20 backdrop-blur-sm" />
)}

{cursorStyle === 'sparkle' && (
<div className="relative flex h-6 w-6 items-center justify-center text-amber-400 animate-pulse text-sm">
</div>
)}

{cursorStyle === 'orbit' && (
<div className="relative h-7 w-7 animate-spin rounded-full border-2 border-dashed border-silver-bright" />
)}

{cursorStyle === 'chess' && (
<div className="flex h-7 w-7 items-center justify-center text-lg drop-shadow-[0_2px_4px_rgba(0,0,0,0.6)]">
♟️
</div>
)}
</div>
</div>
)
}
42 changes: 42 additions & 0 deletions frontend/src/context/CursorContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React, { createContext, useContext, useState } from 'react'

export type CursorStyle = 'default' | 'glow' | 'trail' | 'sparkle' | 'orbit' | 'chess'

interface CursorContextType {
cursorStyle: CursorStyle
setCursorStyle: (style: CursorStyle) => void
}

const CursorContext = createContext<CursorContextType | undefined>(undefined)

const STORAGE_KEY = 'secuscan_cursor_style'

export function CursorProvider({ children }: { children: React.ReactNode }) {
const [cursorStyle, setCursorStyleState] = useState<CursorStyle>(() => {
const saved = localStorage.getItem(STORAGE_KEY)
return (saved as CursorStyle) || 'default'
})

const setCursorStyle = (style: CursorStyle) => {
setCursorStyleState(style)
localStorage.setItem(STORAGE_KEY, style)
}

return (
<CursorContext.Provider value={{ cursorStyle, setCursorStyle }}>
{children}
</CursorContext.Provider>
)
}

export function useCursor(): CursorContextType {
const context = useContext(CursorContext)
if (!context) {
// Return a safe fallback for unit tests rendered outside CursorProvider
return {
cursorStyle: 'default',
setCursorStyle: () => {},
}
}
return context
}
Loading