init
This commit is contained in:
55
frontend/components/ui/button.tsx
Normal file
55
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
78
frontend/components/ui/card.tsx
Normal file
78
frontend/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
41
frontend/components/ui/collapsible.tsx
Normal file
41
frontend/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from './button'
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Collapsible({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-lg', className)}>
|
||||
<Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
variant="ghost"
|
||||
className="w-full justify-between p-4 h-auto font-semibold hover:bg-accent"
|
||||
>
|
||||
<span>{title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-5 w-5 transition-transform duration-200',
|
||||
isOpen && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{isOpen && <div className="p-4 pt-0 border-t">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
frontend/components/ui/compass-selector.tsx
Normal file
221
frontend/components/ui/compass-selector.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface CompassSelectorProps {
|
||||
value: number // 0-360 degrees
|
||||
onChange: (value: number) => void
|
||||
range?: number // Optional range to display as arc (±degrees)
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CompassSelector({ value, onChange, range, size = 200, className = '' }: CompassSelectorProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const compassRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging || !compassRef.current) return
|
||||
|
||||
const rect = compassRef.current.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
const dx = e.clientX - centerX
|
||||
const dy = e.clientY - centerY
|
||||
|
||||
// Calculate angle in degrees (0° = North/top)
|
||||
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
||||
if (angle < 0) angle += 360
|
||||
|
||||
// Round to nearest 5 degrees for easier selection
|
||||
angle = Math.round(angle / 5) * 5
|
||||
|
||||
onChange(angle % 360)
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
setIsDragging(true)
|
||||
// Trigger initial update
|
||||
const rect = compassRef.current!.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
const dx = e.clientX - centerX
|
||||
const dy = e.clientY - centerY
|
||||
|
||||
let angle = Math.atan2(dx, -dy) * (180 / Math.PI)
|
||||
if (angle < 0) angle += 360
|
||||
angle = Math.round(angle / 5) * 5
|
||||
|
||||
onChange(angle % 360)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}
|
||||
}, [isDragging])
|
||||
|
||||
// Convert degrees to radians for calculations
|
||||
const angleRad = (value - 90) * (Math.PI / 180)
|
||||
const radius = size / 2 - 10
|
||||
const needleLength = radius * 0.7
|
||||
|
||||
// Calculate needle endpoint
|
||||
const needleX = size / 2 + Math.cos(angleRad) * needleLength
|
||||
const needleY = size / 2 + Math.sin(angleRad) * needleLength
|
||||
|
||||
// Helper to convert degrees to compass direction
|
||||
const getCompassDirection = (degrees: number): string => {
|
||||
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const index = Math.round(degrees / 22.5) % 16
|
||||
return directions[index]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center ${className}`}>
|
||||
<svg
|
||||
ref={compassRef}
|
||||
width={size}
|
||||
height={size}
|
||||
className="cursor-pointer select-none"
|
||||
onPointerDown={handlePointerDown}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
{/* Outer circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="hsl(var(--secondary))"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Acceptable range arc (green) */}
|
||||
{range !== undefined && (() => {
|
||||
const startAngle = value - range
|
||||
const endAngle = value + range
|
||||
|
||||
// Convert to radians for calculation (adjusting for SVG coordinate system)
|
||||
const startRad = (startAngle - 90) * (Math.PI / 180)
|
||||
const endRad = (endAngle - 90) * (Math.PI / 180)
|
||||
|
||||
// Calculate start and end points on the circle
|
||||
const x1 = size / 2 + Math.cos(startRad) * radius
|
||||
const y1 = size / 2 + Math.sin(startRad) * radius
|
||||
const x2 = size / 2 + Math.cos(endRad) * radius
|
||||
const y2 = size / 2 + Math.sin(endRad) * radius
|
||||
|
||||
// Determine if the arc should be large (>180°) or small
|
||||
const largeArcFlag = range * 2 > 180 ? 1 : 0
|
||||
|
||||
// Create SVG arc path
|
||||
const arcPath = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`
|
||||
|
||||
return (
|
||||
<path
|
||||
d={arcPath}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Cardinal direction markers */}
|
||||
{[
|
||||
{ angle: 0, label: 'N' },
|
||||
{ angle: 90, label: 'E' },
|
||||
{ angle: 180, label: 'S' },
|
||||
{ angle: 270, label: 'W' },
|
||||
].map(({ angle, label }) => {
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
const x = size / 2 + Math.cos(rad) * (radius - 20)
|
||||
const y = size / 2 + Math.sin(rad) * (radius - 20)
|
||||
|
||||
return (
|
||||
<text
|
||||
key={angle}
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-sm font-bold fill-foreground"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Degree tick marks every 30° */}
|
||||
{Array.from({ length: 12 }, (_, i) => {
|
||||
const angle = i * 30
|
||||
const rad = (angle - 90) * (Math.PI / 180)
|
||||
const x1 = size / 2 + Math.cos(rad) * (radius - 5)
|
||||
const y1 = size / 2 + Math.sin(rad) * (radius - 5)
|
||||
const x2 = size / 2 + Math.cos(rad) * radius
|
||||
const y2 = size / 2 + Math.sin(rad) * radius
|
||||
|
||||
return (
|
||||
<line
|
||||
key={angle}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center dot */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r="4"
|
||||
fill="hsl(var(--primary))"
|
||||
/>
|
||||
|
||||
{/* Direction needle */}
|
||||
<line
|
||||
x1={size / 2}
|
||||
y1={size / 2}
|
||||
x2={needleX}
|
||||
y2={needleY}
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Needle tip */}
|
||||
<circle
|
||||
cx={needleX}
|
||||
cy={needleY}
|
||||
r="6"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Value display */}
|
||||
<div className="mt-2 text-center">
|
||||
<div className="text-lg font-bold">{value}° ({getCompassDirection(value)})</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/components/ui/slider.tsx
Normal file
36
frontend/components/ui/slider.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const values = props.value || props.defaultValue || [0]
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
{values.map((_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={index}
|
||||
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
})
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
Reference in New Issue
Block a user