feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
@@ -0,0 +1,146 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { Wand2 } from 'lucide-react'
import type { Material } from '../../api/materials'
const TYPE_GROUPS: Record<string, { label: string; color: string }> = {
'01': { label: 'Metals', color: 'text-slate-500' },
'02': { label: 'Coatings', color: 'text-blue-500' },
'03': { label: 'Non-metals', color: 'text-amber-600' },
'04': { label: 'Compounds', color: 'text-purple-500' },
'05': { label: 'Misc', color: 'text-content-muted' },
}
function getTypeCode(mat: Material): string | null {
if (mat.schaeffler_code == null) return null
const s = String(mat.schaeffler_code).padStart(6, '0')
return s.slice(0, 2)
}
/** Extract the human-readable short name after the last underscore: SCHAEFFLER_010101_Steel-Bare -> Steel-Bare */
function shortName(name: string): string {
const match = name.match(/^SCHAEFFLER_\d{6}_(.+)$/)
return match ? match[1].replace(/-/g, ' ') : name
}
export interface MaterialInputProps {
value: string
onChange: (v: string) => void
library: Material[]
missing: boolean
onOpenWizard: () => void
}
export default function MaterialInput({ value, onChange, library, missing, onOpenWizard }: MaterialInputProps) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const trimmed = value.trim()
const suggestions = trimmed
? library.filter((m) => m.name.toLowerCase().includes(trimmed.toLowerCase())
|| shortName(m.name).toLowerCase().includes(trimmed.toLowerCase())
|| (m.description ?? '').toLowerCase().includes(trimmed.toLowerCase()))
: library
// Group suggestions by type code
const grouped = useMemo(() => {
const groups: Array<{ code: string | null; label: string; color: string; items: Material[] }> = []
const buckets = new Map<string | null, Material[]>()
for (const m of suggestions) {
const tc = getTypeCode(m)
if (!buckets.has(tc)) buckets.set(tc, [])
buckets.get(tc)!.push(m)
}
// Sorted type codes first, then non-schaeffler
const sortedKeys = [...buckets.keys()].sort((a, b) => {
if (a === null) return 1
if (b === null) return -1
return a.localeCompare(b)
})
for (const key of sortedKeys) {
const info = key ? TYPE_GROUPS[key] : null
groups.push({
code: key,
label: info?.label ?? 'Custom',
color: info?.color ?? 'text-content-muted',
items: buckets.get(key)!,
})
}
return groups
}, [suggestions])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const select = (name: string) => {
onChange(name)
setOpen(false)
}
return (
<div ref={wrapRef} className="relative">
<input
type="text"
value={value}
onChange={(e) => { onChange(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
placeholder={missing ? 'Required — assign a material' : 'Search materials...'}
className={`w-full px-2 py-1 text-sm border rounded focus:outline-none bg-surface ${
missing
? 'border-red-300 focus:border-red-500 placeholder-red-300'
: 'border-border-default focus:border-accent'
}`}
/>
{open && (suggestions.length > 0 || true) && (
<div className="absolute left-0 top-full mt-0.5 w-80 border border-border-default rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
{grouped.map((group) => (
<div key={group.code ?? 'custom'}>
{/* Group header */}
<div className="sticky top-0 px-3 py-1 border-b border-border-light" style={{ backgroundColor: 'var(--color-bg-app)' }}>
<span className={`text-[10px] font-bold uppercase tracking-wider ${group.color}`}>
{group.code ? `${group.code} ` : ''}{group.label}
</span>
</div>
{group.items.map((m) => (
<button
key={m.id}
onMouseDown={(e) => { e.preventDefault(); select(m.name) }}
className="w-full text-left px-3 py-1.5 hover:bg-accent-light flex items-baseline gap-2"
>
<span className="text-sm font-medium text-content truncate">{shortName(m.name)}</span>
{m.description && (
<span className="text-xs text-content-muted truncate">{m.description}</span>
)}
</button>
))}
</div>
))}
{suggestions.length === 0 && (
<div className="px-3 py-3 text-center text-xs text-content-muted">No materials match "{trimmed}"</div>
)}
{/* Create new material via wizard */}
<button
onMouseDown={(e) => { e.preventDefault(); setOpen(false); onOpenWizard() }}
className="w-full text-left px-3 py-2 border-t border-border-default flex items-center gap-2 hover:bg-surface-hover text-accent sticky bottom-0"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<Wand2 size={13} />
<span className="text-sm font-medium">Create new material...</span>
</button>
</div>
)}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { cn } from '../../utils/format'
interface ModalProps {
title: string
onClose: () => void
children: React.ReactNode
/** Extra classes applied to the inner panel */
className?: string
/** Width preset defaults to 'md' */
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
}
const sizeMap: Record<NonNullable<ModalProps['size']>, string> = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-full mx-4',
}
export default function Modal({ title, onClose, children, className, size = 'md' }: ModalProps) {
const backdropRef = useRef<HTMLDivElement>(null)
/* Close on Escape */
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
/* Prevent scroll on body while modal is open */
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === backdropRef.current) onClose()
}
return (
<div
ref={backdropRef}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
>
<div
className={cn(
'relative w-full rounded-xl shadow-2xl flex flex-col max-h-[90vh]',
sizeMap[size],
className,
)}
style={{ backgroundColor: 'var(--color-bg-surface)' }}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default shrink-0">
<h2 id="modal-title" className="text-lg font-semibold text-content">
{title}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-md text-content-muted hover:text-content-secondary hover:bg-surface-muted transition-colors"
aria-label="Close"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1">{children}</div>
</div>
</div>
)
}