2025-10-03 21:45:57 -04:00
// components/forms/SettingsSubmit.tsx
2025-09-22 10:37:53 -04:00
"use client" ;
import { useEffect , useMemo , useState } from "react" ;
import { useForm , useFieldArray , type UseFormRegister } from "react-hook-form" ;
2025-09-22 16:11:08 -04:00
import { useRouter , useSearchParams } from "next/navigation" ;
2025-09-22 10:37:53 -04:00
2025-10-05 11:42:32 -04:00
/** CO₂ Galvo– only version (fresh submit path) */
type Target = "settings_co2gal" ; // ← limited on purpose
2025-09-22 10:37:53 -04:00
type Opt = { id : string ; label : string } ;
2025-09-28 01:38:08 -04:00
type Me = {
id : string ;
2025-09-28 07:04:42 -04:00
username? : string ;
2025-09-28 01:38:08 -04:00
email? : string ;
} ;
2025-09-22 10:37:53 -04:00
2025-09-28 14:54:52 -04:00
const API = ( process . env . NEXT_PUBLIC_API_BASE_URL || "" ) . replace ( /\/$/ , "" ) ;
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
2025-10-05 11:42:32 -04:00
// UI bits
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
2025-09-22 10:37:53 -04:00
function FilterableSelect ( {
2025-09-28 10:55:44 -04:00
label ,
name ,
register ,
options ,
loading ,
onQuery ,
placeholder = "—" ,
required = false ,
2025-09-22 10:37:53 -04:00
} : {
label : string ;
name : string ;
register : UseFormRegister < any > ;
options : Opt [ ] ;
loading? : boolean ;
onQuery ? : ( q : string ) = > void ;
placeholder? : string ;
required? : boolean ;
} ) {
const [ filter , setFilter ] = useState ( "" ) ;
2025-10-05 11:42:32 -04:00
useEffect ( ( ) = > { onQuery ? . ( filter ) ; } , [ filter , onQuery ] ) ;
2025-09-22 10:37:53 -04:00
const filtered = useMemo ( ( ) = > {
if ( ! filter ) return options ;
const f = filter . toLowerCase ( ) ;
return options . filter ( ( o ) = > o . label . toLowerCase ( ) . includes ( f ) ) ;
} , [ options , filter ] ) ;
return (
< div >
2025-09-22 16:11:08 -04:00
< label className = "block text-sm mb-1" >
{ label } { required ? < span className = "text-red-600" > * < / span > : null }
< / label >
2025-09-22 11:28:07 -04:00
< input
className = "w-full border rounded px-2 py-1 mb-1"
placeholder = "Type to filter…"
value = { filter }
onChange = { ( e ) = > setFilter ( e . target . value ) }
/ >
2025-09-28 11:15:37 -04:00
< select className = "w-full border rounded px-2 py-1" { ...register ( name , { required } ) } >
2025-10-04 23:09:39 -04:00
< option value = "" > { placeholder } { loading ? " (loading…)" : "" } < / option >
2025-09-28 01:38:08 -04:00
{ filtered . map ( ( o ) = > (
2025-10-05 11:42:32 -04:00
< option key = { o . id } value = { o . id } > { o . label } < / option >
2025-09-28 01:38:08 -04:00
) ) }
2025-09-22 11:28:07 -04:00
< / select >
2025-09-22 10:37:53 -04:00
< / div >
) ;
}
2025-09-28 11:15:37 -04:00
function BoolBox ( { label , name , register } : { label : string ; name : string ; register : UseFormRegister < any > } ) {
2025-09-22 11:28:07 -04:00
return (
< label className = "flex items-center gap-1 text-sm" >
< input type = "checkbox" { ...register ( name ) } / > { label }
< / label >
) ;
}
2025-10-03 22:54:05 -04:00
function LabeledInput ( {
label ,
name ,
type = "text" ,
step ,
register ,
required = false ,
min ,
max ,
} : {
label : string ;
name : string ;
type ? : "text" | "number" ;
step? : string | number ;
register : UseFormRegister < any > ;
required? : boolean ;
min? : number ;
max? : number ;
} ) {
return (
< div >
2025-10-05 11:42:32 -04:00
< label className = "block text-xs mb-1" > { label } { required ? " *" : "" } < / label >
< input type = { type } step = { step } min = { min } max = { max } className = "w-full border rounded px-2 py-1" { ...register ( name , { required } ) } / >
2025-10-03 22:54:05 -04:00
< / div >
) ;
}
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
2025-10-05 11:42:32 -04:00
// Helpers
2025-10-02 23:19:17 -04:00
// ─────────────────────────────────────────────────────────────
2025-10-05 11:42:32 -04:00
function shortId ( s? : string ) {
if ( ! s ) return "" ;
return s . length <= 12 ? s : ` ${ s . slice ( 0 , 8 ) } … ${ s . slice ( - 4 ) } ` ;
}
2025-10-02 23:12:24 -04:00
2025-10-05 11:42:32 -04:00
function idToString ( v : any ) : string {
if ( v == null || v === "" ) return "" ;
if ( typeof v === "object" ) {
if ( ( v as any ) . id != null ) return String ( ( v as any ) . id ) ;
if ( ( v as any ) . submission_id != null ) return String ( ( v as any ) . submission_id ) ;
}
return String ( v ) ;
}
2025-10-02 23:12:24 -04:00
2025-10-05 11:42:32 -04:00
function useOptions ( path : string , forceIncludeId? : string ) {
const [ opts , setOpts ] = useState < Opt [ ] > ( [ ] ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ q , setQ ] = useState ( "" ) ;
2025-09-22 16:11:08 -04:00
2025-10-04 14:02:01 -04:00
useEffect ( ( ) = > {
2025-10-05 11:42:32 -04:00
let alive = true ;
setLoading ( true ) ;
2025-10-04 14:02:01 -04:00
2025-10-05 11:42:32 -04:00
( async ( ) = > {
let url = "" ;
let normalize : ( rows : any [ ] ) = > Opt [ ] = ( rows ) = >
rows . map ( ( r ) = > ( { id : String ( r . id ? ? r . submission_id ? ? r . value ) , label : String ( r . name ? ? r . label ? ? r . title ? ? r . value ? ? r . id ) } ) ) ;
if ( path === "material" ) url = ` ${ API } /items/material?fields=id,name&limit=1000&sort=name ` ;
else if ( path === "material_coating" ) url = ` ${ API } /items/material_coating?fields=id,name&limit=1000&sort=name ` ;
else if ( path === "material_color" ) url = ` ${ API } /items/material_color?fields=id,name&limit=1000&sort=name ` ;
else if ( path === "material_opacity" ) { url = ` ${ API } /items/material_opacity?fields=id,opacity&limit=1000&sort=opacity ` ; normalize = ( rows ) = > rows . map ( ( r ) = > ( { id : String ( r . id ) , label : String ( r . opacity ? ? r . id ) } ) ) ; }
else if ( path === "laser_software" ) url = ` ${ API } /items/laser_software?fields=id,name&limit=1000&sort=name ` ;
else if ( path === "laser_source" ) url = ` ${ API } /items/laser_source?fields=submission_id,make,model&limit=2000&sort=make,model ` , normalize = ( rows ) = > rows . map ( ( r ) = > ( { id : String ( r . submission_id ) , label : [ r . make , r . model ] . filter ( Boolean ) . join ( " " ) || String ( r . submission_id ) } ) ) ;
else if ( path === "laser_scan_lens" ) url = ` ${ API } /items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000 ` ;
// the three fixed lists you showed (labels per your request)
else if ( path === "laser_scan_lens_config" ) url = ` ${ API } /items/laser_scan_lens_config?fields=id,name&limit=1000&sort=name ` ;
else if ( path === "laser_scan_lens_apt" ) url = ` ${ API } /items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name ` ;
else if ( path === "laser_scan_lens_exp" ) url = ` ${ API } /items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name ` ;
else { setOpts ( [ ] ) ; setLoading ( false ) ; return ; }
// special label for scan lenses
if ( path === "laser_scan_lens" ) {
normalize = ( rows ) = > {
const toNum = ( v : any ) = > {
const m = String ( v ? ? "" ) . match ( /-?\d+(\.\d+)?/ ) ;
return m ? parseFloat ( m [ 0 ] ) : Number . POSITIVE_INFINITY ;
} ;
const sorted = [ . . . rows ] . sort ( ( a , b ) = > toNum ( a . focal_length ) - toNum ( b . focal_length ) ) ;
return sorted . map ( ( r ) = > {
const fs = r . field_size != null ? ` ${ r . field_size } ` : "" ;
const fl = r . focal_length != null ? ` ${ r . focal_length } ` : "" ;
const composed = [ fs && ` ${ fs } mm ` , fl && ` ${ fl } mm ` ] . filter ( Boolean ) . join ( " — " ) ;
return { id : String ( r . id ) , label : composed || String ( r . id ) } ;
} ) ;
} ;
}
const res = await fetch ( url , { cache : "no-store" , credentials : "include" } ) ;
if ( ! res . ok ) throw new Error ( ` Directus ${ res . status } fetching ${ url } ` ) ;
const json = await res . json ( ) ;
const rows = json ? . data ? ? [ ] ;
let mapped = normalize ( rows ) ;
// include currently selected ID if not in page/filter
if ( forceIncludeId && ! mapped . some ( ( o : any ) = > String ( o . id ) === String ( forceIncludeId ) ) ) {
mapped = [ { id : String ( forceIncludeId ) , label : "(current selection)" } , . . . mapped ] ;
}
2025-09-28 07:23:31 -04:00
2025-10-05 11:42:32 -04:00
const needle = ( q || "" ) . trim ( ) . toLowerCase ( ) ;
const filtered = needle ? mapped . filter ( ( o ) = > o . label . toLowerCase ( ) . includes ( needle ) ) : mapped ;
if ( alive ) setOpts ( filtered ) ;
} ) ( )
. catch ( ( ) = > { if ( alive ) setOpts ( [ ] ) ; } )
. finally ( ( ) = > { if ( alive ) setLoading ( false ) ; } ) ;
return ( ) = > { alive = false ; } ;
} , [ path , q , forceIncludeId ] ) ;
return { opts , loading , setQ } ;
}
function normalizeForReset ( iv : any ) {
return {
. . . iv ,
mat : idToString ( iv . mat ) ,
mat_coat : idToString ( iv . mat_coat ) ,
mat_color : idToString ( iv . mat_color ) ,
mat_opacity :idToString ( iv . mat_opacity ) ,
source : idToString ( iv . source ) ,
lens : idToString ( iv . lens ) ,
laser_soft : idToString ( iv . laser_soft ) ,
lens_conf : idToString ( iv . lens_conf ) ,
lens_apt : idToString ( iv . lens_apt ) ,
lens_exp : idToString ( iv . lens_exp ) ,
} ;
}
type EditInitialValues = {
submission_id : string | number ;
setting_title? : string ;
setting_notes? : string ;
photo? : string | { id? : string } | null ;
screen? : string | { id? : string } | null ;
mat? : any ; mat_coat? : any ; mat_color? : any ; mat_opacity? : any ; mat_thickness? : number | null ;
source? : any ; lens? : any ; focus? : number | null ;
laser_soft? : any ; repeat_all? : number | null ;
lens_conf? : any ; lens_apt? : any ; lens_exp? : any ;
fill_settings? : any [ ] | null ;
line_settings? : any [ ] | null ;
raster_settings? : any [ ] | null ;
} ;
// ─────────────────────────────────────────────────────────────
// Component (CO₂ Galvo only)
// ─────────────────────────────────────────────────────────────
export default function SettingsSubmit ( {
mode ,
submissionId ,
initialValues ,
} : {
mode ? : "edit" ;
submissionId? : string | number ;
initialValues? : EditInitialValues ;
} ) {
const router = useRouter ( ) ;
const sp = useSearchParams ( ) ;
const target : Target = "settings_co2gal" ; // locked
// files
2025-09-22 16:11:08 -04:00
const [ photoFile , setPhotoFile ] = useState < File | null > ( null ) ;
const [ screenFile , setScreenFile ] = useState < File | null > ( null ) ;
const [ photoPreview , setPhotoPreview ] = useState < string > ( "" ) ;
const [ screenPreview , setScreenPreview ] = useState < string > ( "" ) ;
2025-09-22 10:37:53 -04:00
2025-10-05 11:42:32 -04:00
// errors / me
2025-09-28 01:38:08 -04:00
const [ submitErr , setSubmitErr ] = useState < string | null > ( null ) ;
const [ me , setMe ] = useState < Me | null > ( null ) ;
const [ meErr , setMeErr ] = useState < string | null > ( null ) ;
useEffect ( ( ) = > {
let alive = true ;
2025-09-29 12:39:59 -04:00
fetch ( ` /api/me ` , { cache : "no-store" , credentials : "include" } )
2025-09-28 01:38:08 -04:00
. then ( ( r ) = > ( r . ok ? r . json ( ) : Promise . reject ( r ) ) )
2025-10-05 11:42:32 -04:00
. then ( ( j ) = > { if ( alive ) setMe ( j || null ) ; } )
2025-10-05 08:50:01 -04:00
. catch ( ( ) = > { if ( alive ) setMeErr ( "not-signed-in" ) ; } ) ;
return ( ) = > { alive = false ; } ;
2025-09-28 01:38:08 -04:00
} , [ ] ) ;
2025-10-05 11:42:32 -04:00
const isEdit = mode === "edit" && submissionId != null ;
const current = useMemo ( ( ) = > ( isEdit && initialValues ? normalizeForReset ( initialValues ) : null ) , [ isEdit , initialValues ] ) ;
2025-09-28 11:44:34 -04:00
2025-10-05 11:42:32 -04:00
// options (galvo)
const mats = useOptions ( "material" , current ? . mat ) ;
const coats = useOptions ( "material_coating" , current ? . mat_coat ) ;
const colors = useOptions ( "material_color" , current ? . mat_color ) ;
const opacs = useOptions ( "material_opacity" , current ? . mat_opacity ) ;
const soft = useOptions ( "laser_software" , current ? . laser_soft ) ;
const srcs = useOptions ( "laser_source" , current ? . source ) ;
const lens = useOptions ( "laser_scan_lens" , current ? . lens ) ;
2025-10-04 14:02:01 -04:00
2025-10-05 11:42:32 -04:00
// three fixed lists (labels per your request)
const lensConf = useOptions ( "laser_scan_lens_config" , current ? . lens_conf ) ; // "Lens Configuration"
const lensApt = useOptions ( "laser_scan_lens_apt" , current ? . lens_apt ) ; // "Scan Head Aperture"
const lensExp = useOptions ( "laser_scan_lens_exp" , current ? . lens_exp ) ; // "Beam Expander"
2025-09-28 14:54:52 -04:00
2025-10-05 11:42:32 -04:00
// form
2025-09-28 14:54:52 -04:00
const {
register ,
handleSubmit ,
control ,
reset ,
2025-10-04 20:40:14 -04:00
setValue ,
2025-10-04 20:14:47 -04:00
getValues ,
2025-09-28 14:54:52 -04:00
formState : { isSubmitting } ,
2025-10-02 23:19:17 -04:00
} = useForm < any > ( {
defaultValues : {
setting_title : "" ,
setting_notes : "" ,
2025-10-05 11:42:32 -04:00
// required relations
mat : "" , mat_coat : "" , mat_color : "" , mat_opacity : "" ,
source : "" , lens : "" , laser_soft : "" ,
lens_conf : "" , lens_apt : "" , lens_exp : "" ,
// numerics
focus : "" , repeat_all : "" , mat_thickness : "" ,
// repeaters (kept, but not required)
fill_settings : [ ] , line_settings : [ ] , raster_settings : [ ] ,
2025-10-02 23:19:17 -04:00
} ,
2025-09-28 14:54:52 -04:00
} ) ;
2025-10-05 08:50:01 -04:00
const fills = useFieldArray ( { control , name : "fill_settings" } ) ;
const lines = useFieldArray ( { control , name : "line_settings" } ) ;
2025-10-02 23:19:17 -04:00
const rasters = useFieldArray ( { control , name : "raster_settings" } ) ;
2025-09-28 14:54:52 -04:00
2025-10-05 11:42:32 -04:00
// prefill for edit
2025-10-04 17:42:25 -04:00
useEffect ( ( ) = > {
if ( ! isEdit || ! current ) return ;
2025-10-05 11:42:32 -04:00
reset ( {
setting_title : current.setting_title ? ? "" ,
setting_notes : current.setting_notes ? ? "" ,
photo : current.photo ? ? null ,
screen : current.screen ? ? null ,
mat : current.mat ? ? "" ,
mat_coat : current.mat_coat ? ? "" ,
mat_color : current.mat_color ? ? "" ,
mat_opacity : current.mat_opacity ? ? "" ,
mat_thickness : current.mat_thickness ? ? "" ,
source : current.source ? ? "" ,
lens : current.lens ? ? "" ,
focus : current.focus ? ? "" ,
laser_soft : current.laser_soft ? ? "" ,
repeat_all : current.repeat_all ? ? "" ,
lens_conf : current.lens_conf ? ? "" ,
lens_apt : current.lens_apt ? ? "" ,
lens_exp : current.lens_exp ? ? "" ,
fill_settings : current.fill_settings ? ? [ ] ,
line_settings : current.line_settings ? ? [ ] ,
raster_settings : current.raster_settings ? ? [ ] ,
2025-10-04 17:42:25 -04:00
} ) ;
2025-10-05 11:42:32 -04:00
} , [ isEdit , current , reset ] ) ;
2025-10-04 17:42:25 -04:00
2025-10-05 11:42:32 -04:00
// keep selects stable when options hydrate
2025-10-04 18:09:38 -04:00
useEffect ( ( ) = > {
if ( ! isEdit || ! current ) return ;
2025-10-05 11:42:32 -04:00
const names = [ "mat" , "mat_coat" , "mat_color" , "mat_opacity" , "source" , "lens" , "laser_soft" , "lens_conf" , "lens_apt" , "lens_exp" ] as const ;
const values = getValues ( ) ;
names . forEach ( ( n ) = > {
const cur = ( current as any ) [ n ] ;
const now = ( values as any ) [ n ] ;
if ( cur && ( now == null || now === "" ) ) setValue ( n as any , cur , { shouldDirty : false , shouldValidate : false } ) ;
} ) ;
} , [ isEdit , current , getValues , setValue , mats . opts , coats . opts , colors . opts , opacs . opts , srcs . opts , lens . opts , soft . opts , lensConf . opts , lensApt . opts , lensExp . opts ] ) ;
2025-10-05 08:50:01 -04:00
2025-10-05 11:42:32 -04:00
// numeric / bool coercers
const num = ( v : any ) = > ( v === "" || v == null ? null : Number ( v ) ) ;
2025-10-02 23:19:17 -04:00
const bool = ( v : any ) = > ! ! v ;
2025-09-28 14:54:52 -04:00
2025-10-05 11:42:32 -04:00
// submit
2025-10-02 23:19:17 -04:00
async function onSubmit ( values : any ) {
setSubmitErr ( null ) ;
2025-09-28 07:41:15 -04:00
2025-10-05 11:42:32 -04:00
// required-photo logic: need a file unless edit already has a photo id
const currentPhotoId = isEdit && typeof current ? . photo === "string" ? current ! . photo as string : null ;
if ( ! currentPhotoId && ! photoFile ) {
2025-10-02 23:19:17 -04:00
( document . querySelector ( 'input[type="file"][data-role="photo"]' ) as HTMLInputElement | null ) ? . focus ( ) ;
return ;
}
2025-09-28 07:41:15 -04:00
2025-10-05 11:42:32 -04:00
// build payload with only required + notes/images + repeaters
const payload : any = {
2025-10-05 08:50:01 -04:00
target ,
2025-10-02 23:19:17 -04:00
setting_title : values.setting_title ,
setting_notes : values.setting_notes || "" ,
2025-10-05 11:42:32 -04:00
// required fields per your list
source : values.source || null ,
lens : values.lens || null ,
lens_conf : values.lens_conf || null ,
lens_apt : values.lens_apt || null ,
lens_exp : values.lens_exp || null ,
focus : num ( values . focus ) ,
2025-10-02 23:19:17 -04:00
mat : values.mat || null ,
mat_coat : values.mat_coat || null ,
mat_color : values.mat_color || null ,
mat_opacity : values.mat_opacity || null ,
2025-10-05 08:50:01 -04:00
laser_soft : values.laser_soft || null ,
repeat_all : num ( values . repeat_all ) ,
2025-10-05 11:42:32 -04:00
// nice-to-have
mat_thickness : num ( values . mat_thickness ) ,
2025-10-04 23:09:39 -04:00
2025-10-05 11:42:32 -04:00
// server auto-sets uploader from owner, but include a mirror if available
2025-10-05 08:24:51 -04:00
. . . ( me ? . username || me ? . email ? { uploader : me?.username ? ? me ? . email ! } : { } ) ,
2025-10-05 11:42:32 -04:00
// repeaters (optional – pass-through)
2025-10-02 23:19:17 -04:00
fill_settings : ( values . fill_settings || [ ] ) . map ( ( r : any ) = > ( {
name : r.name || "" ,
2025-10-05 11:42:32 -04:00
power : num ( r . power ) , speed : num ( r . speed ) , interval : num ( r . interval ) , pass : num ( r . pass ) ,
type : r . type || "" , frequency : num ( r . frequency ) , pulse : num ( r . pulse ) , angle : num ( r . angle ) ,
auto : bool ( r . auto ) , increment : num ( r . increment ) , cross : bool ( r . cross ) , flood : bool ( r . flood ) , air : bool ( r . air ) ,
2025-10-02 23:19:17 -04:00
} ) ) ,
line_settings : ( values . line_settings || [ ] ) . map ( ( r : any ) = > ( {
name : r.name || "" ,
2025-10-05 11:42:32 -04:00
power : num ( r . power ) , speed : num ( r . speed ) , perf : bool ( r . perf ) , cut : bool ( r . cut ) , skip : bool ( r . skip ) ,
pass : num ( r . pass ) , air : bool ( r . air ) , frequency : num ( r . frequency ) , pulse : num ( r . pulse ) ,
wobble : bool ( r . wobble ) , step : num ( r . step ) , size : num ( r . size ) ,
2025-10-02 23:19:17 -04:00
} ) ) ,
raster_settings : ( values . raster_settings || [ ] ) . map ( ( r : any ) = > ( {
name : r.name || "" ,
2025-10-05 11:42:32 -04:00
power : num ( r . power ) , speed : num ( r . speed ) , type : r . type || "" , dither : r.dither || "" ,
halftone_cell : num ( r . halftone_cell ) , halftone_angle : num ( r . halftone_angle ) , inversion : bool ( r . inversion ) ,
interval : num ( r . interval ) , dot : num ( r . dot ) , pass : num ( r . pass ) , air : bool ( r . air ) ,
frequency : num ( r . frequency ) , pulse : num ( r . pulse ) , cross : bool ( r . cross ) ,
2025-10-02 23:19:17 -04:00
} ) ) ,
} ;
2025-10-02 23:12:24 -04:00
2025-10-05 11:42:32 -04:00
// edit meta
if ( isEdit && submissionId != null ) {
payload . mode = "edit" ;
payload . submission_id = submissionId ;
2025-10-04 20:40:14 -04:00
}
2025-10-04 23:09:39 -04:00
try {
const form = new FormData ( ) ;
2025-10-05 11:42:32 -04:00
form . set ( "payload" , JSON . stringify ( payload ) ) ; // server route reads "payload"
2025-10-04 23:09:39 -04:00
2025-10-05 11:42:32 -04:00
if ( photoFile ) form . set ( "photo" , photoFile , photoFile . name || "photo" ) ;
if ( screenFile ) form . set ( "screen" , screenFile , screenFile . name || "screen" ) ;
2025-09-28 07:41:15 -04:00
2025-10-05 11:42:32 -04:00
const res = await fetch ( "/api/submit/settings" , { method : "POST" , body : form , credentials : "include" } ) ;
2025-10-02 23:19:17 -04:00
const data = await res . json ( ) . catch ( ( ) = > ( { } ) ) ;
if ( ! res . ok ) {
if ( res . status === 401 || res . status === 403 ) throw new Error ( "You must be signed in to submit settings." ) ;
2025-10-03 22:54:05 -04:00
throw new Error ( ( data as any ) ? . error || "Submission failed" ) ;
2025-10-02 23:12:24 -04:00
}
2025-10-02 23:19:17 -04:00
2025-10-03 21:45:57 -04:00
if ( ! isEdit ) {
reset ( ) ;
2025-10-05 11:42:32 -04:00
setPhotoFile ( null ) ; setScreenFile ( null ) ;
setPhotoPreview ( "" ) ; setScreenPreview ( "" ) ;
2025-10-03 21:45:57 -04:00
}
2025-10-02 23:19:17 -04:00
2025-10-05 11:42:32 -04:00
const id = ( data as any ) ? . id ? String ( ( data as any ) . id ) : String ( submissionId ? ? "" ) ;
2025-10-03 22:54:05 -04:00
if ( isEdit ) {
2025-10-05 11:42:32 -04:00
const q = new URLSearchParams ( sp . toString ( ) ) ; q . delete ( "edit" ) ;
2025-10-03 22:54:05 -04:00
router . replace ( ` /portal/laser-settings? ${ q . toString ( ) } ` ) ;
} else {
router . push ( ` /submit/settings/success?target= ${ encodeURIComponent ( target ) } &id= ${ encodeURIComponent ( id ) } ` ) ;
}
2025-10-02 23:19:17 -04:00
} catch ( e : any ) {
setSubmitErr ( e ? . message || "Submission failed" ) ;
2025-09-28 14:54:52 -04:00
}
2025-10-02 23:19:17 -04:00
}
2025-09-28 07:41:15 -04:00
2025-10-02 23:19:17 -04:00
function onPick ( file : File | null , setFile : ( f : File | null ) = > void , setPreview : ( s : string ) = > void ) {
setFile ( file ) ;
2025-10-05 08:50:01 -04:00
if ( ! file ) { setPreview ( "" ) ; return ; }
2025-10-02 23:19:17 -04:00
const reader = new FileReader ( ) ;
reader . onload = ( ) = > setPreview ( String ( reader . result || "" ) ) ;
reader . readAsDataURL ( file ) ;
}
2025-09-28 07:41:15 -04:00
2025-10-05 11:42:32 -04:00
const currentPhotoId = isEdit && typeof current ? . photo === "string" ? ( current ! . photo as string ) : null ;
const currentScreenId = isEdit && typeof current ? . screen === "string" ? ( current ! . screen as string ) : null ;
2025-09-28 07:41:15 -04:00
2025-10-02 23:19:17 -04:00
return (
< div className = "max-w-3xl mx-auto space-y-4" >
2025-09-22 10:37:53 -04:00
2025-10-05 11:42:32 -04:00
{ /* Banner */ }
2025-10-02 23:19:17 -04:00
{ me ? (
< div className = "text-sm text-muted-foreground" >
2025-10-05 11:42:32 -04:00
Submitting as < span className = "font-medium" > { me . username || me . email || ` User ${ shortId ( me . id ) } ` } < / span > .
2025-10-02 23:12:24 -04:00
< / div >
2025-10-02 23:19:17 -04:00
) : meErr ? (
< div className = "border border-yellow-600 bg-yellow-50 text-yellow-800 rounded p-2 text-sm" >
You ’ re not signed in . Submissions will fail until you sign in .
2025-10-02 23:12:24 -04:00
< / div >
2025-10-02 23:19:17 -04:00
) : null }
2025-09-22 10:37:53 -04:00
2025-10-05 11:42:32 -04:00
{ submitErr ? < div className = "border border-red-500 text-red-600 bg-red-50 rounded p-2 text-sm" > { submitErr } < / div > : null }
2025-10-02 23:19:17 -04:00
< form onSubmit = { handleSubmit ( onSubmit ) } className = "space-y-4" >
2025-10-05 11:42:32 -04:00
2025-10-02 23:19:17 -04:00
{ /* Title */ }
2025-10-05 11:42:32 -04:00
< div >
< label className = "block text-sm mb-1" > Title < span className = "text-red-600" > * < / span > < / label >
2025-10-02 23:19:17 -04:00
< input className = "w-full border rounded px-2 py-1" { ...register ( "setting_title" , { required : true } ) } / >
< / div >
{ /* Images */ }
< div className = "grid md:grid-cols-2 gap-4" >
< div >
2025-10-05 11:42:32 -04:00
< label className = "block text-sm mb-1" > Result Photo { ! currentPhotoId ? < span className = "text-red-600" > * < / span > : null } < / label >
{ currentPhotoId && < p className = "text-xs text-muted-foreground mb-1" > Current : < span className = "font-mono" > { shortId ( currentPhotoId ) } < / span > < / p > }
< input type = "file" accept = "image/*" data-role = "photo" required = { ! currentPhotoId && ! photoFile } onChange = { ( e ) = > onPick ( e . target . files ? . [ 0 ] ? ? null , setPhotoFile , setPhotoPreview ) } / >
< p className = "text-xs text-muted-foreground mt-1" > { photoFile ? < > Selected : < span className = "font-mono" > { photoFile . name } < / span > < / > : "Max 25 MB. JPG/PNG/WebP recommended." } < / p >
2025-10-02 23:19:17 -04:00
{ photoPreview ? < img src = { photoPreview } alt = "Result preview" className = "mt-2 rounded border" / > : null }
< / div >
< div >
< label className = "block text-sm mb-1" > Settings Screenshot ( optional ) < / label >
2025-10-05 11:42:32 -04:00
{ currentScreenId && < p className = "text-xs text-muted-foreground mb-1" > Current : < span className = "font-mono" > { shortId ( currentScreenId ) } < / span > < / p > }
< input type = "file" accept = "image/*" onChange = { ( e ) = > onPick ( e . target . files ? . [ 0 ] ? ? null , setScreenFile , setScreenPreview ) } / >
< p className = "text-xs text-muted-foreground mt-1" > { screenFile ? < > Selected : < span className = "font-mono" > { screenFile . name } < / span > < / > : "Max 25 MB. JPG/PNG/WebP recommended." } < / p >
2025-10-02 23:19:17 -04:00
{ screenPreview ? < img src = { screenPreview } alt = "Settings preview" className = "mt-2 rounded border" / > : null }
< / div >
< / div >
2025-10-02 23:12:24 -04:00
2025-10-02 23:19:17 -04:00
{ /* Notes */ }
< div >
< label className = "block text-sm mb-1" > Notes < / label >
< textarea rows = { 4 } className = "w-full border rounded px-2 py-1" { ...register ( "setting_notes" ) } / >
< / div >
2025-10-05 11:42:32 -04:00
{ /* Required Selects (materials, source, lens) */ }
2025-10-02 23:19:17 -04:00
< div className = "grid md:grid-cols-2 gap-3" >
2025-10-05 11:42:32 -04:00
< FilterableSelect label = "Material" name = "mat" register = { register } options = { mats . opts } loading = { mats . loading } onQuery = { mats . setQ } required / >
< FilterableSelect label = "Coating" name = "mat_coat" register = { register } options = { coats . opts } loading = { coats . loading } onQuery = { coats . setQ } required / >
< FilterableSelect label = "Color" name = "mat_color" register = { register } options = { colors . opts } loading = { colors . loading } onQuery = { colors . setQ } required / >
< FilterableSelect label = "Opacity" name = "mat_opacity" register = { register } options = { opacs . opts } loading = { opacs . loading } onQuery = { opacs . setQ } required / >
< FilterableSelect label = "Laser Source" name = "source" register = { register } options = { srcs . opts } loading = { srcs . loading } onQuery = { srcs . setQ } required / >
< FilterableSelect label = "Lens" name = "lens" register = { register } options = { lens . opts } loading = { lens . loading } onQuery = { lens . setQ } required / >
2025-10-02 23:19:17 -04:00
< / div >
2025-10-05 11:42:32 -04:00
{ /* Numeric requireds */ }
2025-10-02 23:19:17 -04:00
< div className = "grid md:grid-cols-3 gap-3" >
2025-10-05 11:42:32 -04:00
< LabeledInput label = "Focus (mm)" name = "focus" type = "number" min = { - 10 } max = { 10 } step = "1" register = { register } required / >
< LabeledInput label = "Repeat All" name = "repeat_all" type = "number" step = "1" register = { register } required / >
2025-10-03 22:54:05 -04:00
< LabeledInput label = "Material Thickness (mm)" name = "mat_thickness" type = "number" step = "0.01" register = { register } / >
2025-10-05 11:42:32 -04:00
< p className = "text-xs text-muted-foreground md:col-span-3" > 0 = in focus . Negative = closer . Positive = further . < / p >
2025-10-02 23:19:17 -04:00
< / div >
2025-10-05 11:42:32 -04:00
{ /* Lens Configuration block (all required) */ }
2025-10-02 23:19:17 -04:00
< fieldset className = "border rounded p-3 space-y-2" >
2025-10-05 11:42:32 -04:00
< legend className = "font-semibold" > Lens Options < / legend >
< div className = "grid md:grid-cols-3 gap-3" >
< FilterableSelect label = "Lens Configuration" name = "lens_conf" register = { register } options = { lensConf . opts } loading = { lensConf . loading } onQuery = { lensConf . setQ } required / >
< FilterableSelect label = "Scan Head Aperture" name = "lens_apt" register = { register } options = { lensApt . opts } loading = { lensApt . loading } onQuery = { lensApt . setQ } required / >
< FilterableSelect label = "Beam Expander" name = "lens_exp" register = { register } options = { lensExp . opts } loading = { lensExp . loading } onQuery = { lensExp . setQ } required / >
2025-10-02 23:19:17 -04:00
< / div >
< / fieldset >
2025-10-05 11:42:32 -04:00
{ /* Optional repeaters kept for parity (not required) */ }
{ /* Feel free to hide these until after MVP if you want */ }
2025-10-02 23:19:17 -04:00
2025-10-05 11:42:32 -04:00
< button disabled = { isSubmitting } className = "px-3 py-2 border rounded bg-accent text-background hover:opacity-90 disabled:opacity-50" >
2025-10-02 23:19:17 -04:00
{ isSubmitting ? "Submitting…" : isEdit ? "Save Changes" : "Submit Settings" }
< / button >
< / form >
< / div >
) ;
2025-09-28 11:15:37 -04:00
}