1+ import React , { useState , useCallback , useEffect , useRef } from 'react' ;
2+ import { generateChatResponses } from './services/geminiService' ;
3+ import {
4+ Button , Select , MenuItem , Checkbox , FormControlLabel , Snackbar , AppBar , Toolbar , Typography , IconButton , Container , Paper , Box , Modal , CssBaseline
5+ } from '@mui/material' ;
6+ import type { SavedMessageSet } from './types' ;
7+ import WebSettingsModal from './src/components/WebSettingsModal' ;
8+ import { webSettingsService , type WebAppConfig } from './services/webSettingsService' ;
9+ import { ThemeProvider , createTheme } from '@mui/material/styles' ;
10+
11+ type CustomPromptType = string | { prompt : string ; label ?: string ; type ?: string } ;
12+
13+ // Minimal ErrorBoundary for runtime safety
14+ class ErrorBoundary extends React . Component < { children : React . ReactNode } , { hasError : boolean } > {
15+ constructor ( props : { children : React . ReactNode } ) {
16+ super ( props ) ;
17+ this . state = { hasError : false } ;
18+ }
19+ static getDerivedStateFromError ( ) { return { hasError : true } ; }
20+ render ( ) { return this . state . hasError ? < div > Something went wrong.</ div > : this . props . children ; }
21+ }
22+
23+ function WebApp ( ) {
24+ // --- State ---
25+ const [ selectedCustomPrompt ] = useState < CustomPromptType > ( '' ) ;
26+ const hotkeyHints = [
27+ { key : 'Ctrl+Alt+S' , label : 'Screenshot' } ,
28+ { key : 'Ctrl+Alt+G' , label : 'Generate' } ,
29+ { key : 'Ctrl+Alt+F' , label : 'Funnier' } ,
30+ { key : 'Ctrl+Alt+N' , label : 'New Image' }
31+ ] ;
32+ const [ autoGenerate , setAutoGenerate ] = useState ( ( ) => {
33+ const saved = localStorage . getItem ( 'autoGenerate' ) ;
34+ return saved !== null ? JSON . parse ( saved ) : true ;
35+ } ) ;
36+ const [ notification , setNotification ] = useState < string | undefined > ( undefined ) ;
37+ const [ isDragging , setIsDragging ] = useState ( false ) ;
38+ const [ showSettings , setShowSettings ] = useState ( false ) ;
39+ const [ customButtonLabel , setCustomButtonLabel ] = useState ( 'Custom Action' ) ;
40+ const [ customActionEnabled , setCustomActionEnabled ] = useState ( true ) ;
41+ const fileInputRef = useRef < HTMLInputElement > ( null ) ;
42+
43+ // Add missing state for screenshotUrl, responses, error, isLoading
44+ const [ screenshotUrl , setScreenshotUrl ] = useState < string | undefined > ( undefined ) ;
45+ const [ responses , setResponses ] = useState < any > ( undefined ) ;
46+ const [ error , setError ] = useState < string | undefined > ( undefined ) ;
47+ const [ isLoading , setIsLoading ] = useState ( false ) ;
48+
49+ // --- Handlers ---
50+ const triggerFileInput = useCallback ( ( ) => {
51+ if ( fileInputRef . current ) fileInputRef . current . click ( ) ;
52+ } , [ ] ) ;
53+
54+ const handleGeneration = useCallback (
55+ async (
56+ makeItFunnier = false ,
57+ imageUrl ?: string ,
58+ customPromptId ?: string ,
59+ messageCount = 5
60+ ) => {
61+ setIsLoading ( true ) ;
62+ setError ( undefined ) ;
63+ setResponses ( undefined ) ;
64+ const funninessLevel = makeItFunnier ? 2 : 1 ;
65+ const customPrompt = customPromptId || ( typeof selectedCustomPrompt === 'string' ? selectedCustomPrompt : selectedCustomPrompt . prompt ) ;
66+ const imageToUse = imageUrl ?? screenshotUrl ?? undefined ;
67+ try {
68+ const response = await generateChatResponses ( imageToUse || '' , makeItFunnier , customPrompt , messageCount ) ;
69+ setResponses ( response ) ;
70+ } catch ( err : any ) {
71+ setError ( err ?. message || 'Failed to generate chat messages' ) ;
72+ } finally {
73+ setIsLoading ( false ) ;
74+ }
75+ } , [ selectedCustomPrompt , screenshotUrl ] ) ;
76+
77+ const processImageFile = useCallback (
78+ ( fileOrUrl : File | string | undefined , _autoGen : boolean ) => {
79+ let imageUrl : string | undefined = undefined ;
80+ if ( ! fileOrUrl ) {
81+ setError ( 'No image selected' ) ;
82+ return ;
83+ }
84+ if ( typeof fileOrUrl === 'string' ) {
85+ imageUrl = fileOrUrl ;
86+ } else {
87+ imageUrl = URL . createObjectURL ( fileOrUrl ) ;
88+ }
89+ setScreenshotUrl ( imageUrl ) ;
90+ setError ( undefined ) ;
91+ setResponses ( undefined ) ;
92+ } , [ ] ) ;
93+
94+ const handleFileChange = useCallback ( ( event : React . ChangeEvent < HTMLInputElement > ) => {
95+ const file = event . target . files ?. [ 0 ] ;
96+ if ( file ) {
97+ processImageFile ( file , autoGenerate ) ;
98+ }
99+ } , [ processImageFile , autoGenerate ] ) ;
100+
101+ const handleDrop = useCallback ( ( event : React . DragEvent ) => {
102+ event . preventDefault ( ) ;
103+ setIsDragging ( false ) ;
104+ const files = event . dataTransfer . files ;
105+ if ( files . length > 0 ) {
106+ processImageFile ( files [ 0 ] , autoGenerate ) ;
107+ }
108+ } , [ processImageFile , autoGenerate ] ) ;
109+
110+ const handleDragOver = useCallback ( ( event : React . DragEvent ) => {
111+ event . preventDefault ( ) ;
112+ setIsDragging ( true ) ;
113+ } , [ ] ) ;
114+
115+ const handleDragLeave = useCallback ( ( event : React . DragEvent ) => {
116+ event . preventDefault ( ) ;
117+ setIsDragging ( false ) ;
118+ } , [ ] ) ;
119+
120+ const resetSelection = useCallback ( ( ) => {
121+ setScreenshotUrl ( undefined ) ;
122+ setResponses ( undefined ) ;
123+ setError ( undefined ) ;
124+ } , [ ] ) ;
125+
126+ const handleConfigUpdate = useCallback ( ( config : WebAppConfig ) => {
127+ if ( typeof config . customActionEnabled === 'boolean' ) {
128+ setCustomActionEnabled ( config . customActionEnabled ) ;
129+ }
130+ if ( typeof config . customButtonLabel === 'string' ) {
131+ setCustomButtonLabel ( config . customButtonLabel ) ;
132+ }
133+ } , [ ] ) ;
134+
135+ // Load config on mount
136+ useEffect ( ( ) => {
137+ const loadConfig = async ( ) => {
138+ try {
139+ const config = await webSettingsService . getConfig ( ) ;
140+ if ( typeof config . customActionEnabled === 'boolean' ) {
141+ setCustomActionEnabled ( config . customActionEnabled ) ;
142+ }
143+ if ( typeof config . customButtonLabel === 'string' ) {
144+ setCustomButtonLabel ( config . customButtonLabel ) ;
145+ }
146+ } catch ( error ) {
147+ console . error ( 'Failed to load config:' , error ) ;
148+ }
149+ } ;
150+ loadConfig ( ) ;
151+ } , [ ] ) ;
152+
153+ // Save autoGenerate to localStorage
154+ useEffect ( ( ) => {
155+ localStorage . setItem ( 'autoGenerate' , JSON . stringify ( autoGenerate ) ) ;
156+ } , [ autoGenerate ] ) ;
157+
158+ const theme = createTheme ( {
159+ palette : {
160+ mode : 'dark' ,
161+ primary : {
162+ main : '#00bcd4' ,
163+ } ,
164+ secondary : {
165+ main : '#f50057' ,
166+ } ,
167+ } ,
168+ } ) ;
169+
170+ return (
171+ < ErrorBoundary >
172+ < ThemeProvider theme = { theme } >
173+ < CssBaseline />
174+ < Box sx = { { flexGrow : 1 } } >
175+ < AppBar position = "static" sx = { { backgroundColor : 'grey.900' } } >
176+ < Toolbar >
177+ < Typography variant = "h6" component = "div" sx = { { flexGrow : 1 } } >
178+ New World Chat AI
179+ </ Typography >
180+ < IconButton
181+ color = "inherit"
182+ title = "Settings"
183+ onClick = { ( ) => setShowSettings ( true ) }
184+ >
185+ < span role = "img" aria-label = "settings" > ⚙️</ span >
186+ </ IconButton >
187+ </ Toolbar >
188+ </ AppBar >
189+
190+ < Container maxWidth = "lg" sx = { { mt : 4 } } >
191+ < Paper
192+ elevation = { 3 }
193+ sx = { {
194+ p : 3 ,
195+ backgroundColor : 'grey.800' ,
196+ border : isDragging ? '2px dashed #00bcd4' : '2px dashed transparent' ,
197+ transition : 'border 0.2s ease-in-out'
198+ } }
199+ onDrop = { handleDrop }
200+ onDragOver = { handleDragOver }
201+ onDragLeave = { handleDragLeave }
202+ >
203+ < Box sx = { { textAlign : 'center' , mb : 3 } } >
204+ < Typography variant = "h5" component = "h1" gutterBottom >
205+ New World Chat AI
206+ </ Typography >
207+ < Typography variant = "body1" color = "text.secondary" gutterBottom >
208+ Upload an image or drag and drop to generate chat messages
209+ </ Typography >
210+ </ Box >
211+
212+ < Box sx = { { display : 'flex' , flexDirection : 'column' , gap : 2 , alignItems : 'center' } } >
213+ < input
214+ ref = { fileInputRef }
215+ type = "file"
216+ accept = "image/*"
217+ onChange = { handleFileChange }
218+ style = { { display : 'none' } }
219+ aria-label = "Select image file"
220+ />
221+
222+ < Button
223+ variant = "contained"
224+ onClick = { triggerFileInput }
225+ sx = { { mb : 2 } }
226+ >
227+ 📸 Select Image
228+ </ Button >
229+
230+ { screenshotUrl && (
231+ < Box sx = { { mt : 2 , textAlign : 'center' } } >
232+ < img
233+ src = { screenshotUrl }
234+ alt = "Screenshot"
235+ style = { { maxWidth : '100%' , maxHeight : '300px' , borderRadius : '8px' } }
236+ />
237+ < Box sx = { { mt : 2 , display : 'flex' , gap : 1 , justifyContent : 'center' , flexWrap : 'wrap' } } >
238+ < Button
239+ variant = "contained"
240+ onClick = { ( ) => handleGeneration ( ) }
241+ disabled = { isLoading }
242+ >
243+ { isLoading ? 'Generating...' : 'Generate Chat Messages' }
244+ </ Button >
245+ < Button
246+ variant = "outlined"
247+ onClick = { ( ) => handleGeneration ( true ) }
248+ disabled = { isLoading }
249+ >
250+ Make it Funnier
251+ </ Button >
252+ < Button
253+ variant = "outlined"
254+ onClick = { resetSelection }
255+ >
256+ New Image
257+ </ Button >
258+ </ Box >
259+ </ Box >
260+ ) }
261+
262+ { error && (
263+ < Typography color = "error" sx = { { mt : 2 } } >
264+ { error }
265+ </ Typography >
266+ ) }
267+
268+ { responses && responses . chatMessages && (
269+ < Box sx = { { mt : 3 , width : '100%' } } >
270+ < Typography variant = "h6" gutterBottom >
271+ Generated Messages:
272+ </ Typography >
273+ < Box sx = { { display : 'flex' , flexDirection : 'column' , gap : 1 } } >
274+ { responses . chatMessages . map ( ( message : any , index : number ) => (
275+ < Paper
276+ key = { index }
277+ sx = { {
278+ p : 2 ,
279+ backgroundColor : 'grey.700' ,
280+ cursor : 'pointer' ,
281+ '&:hover' : { backgroundColor : 'grey.600' }
282+ } }
283+ onClick = { ( ) => {
284+ navigator . clipboard . writeText ( message . text || message . message || message ) ;
285+ setNotification ( 'Message copied to clipboard!' ) ;
286+ } }
287+ >
288+ < Typography variant = "body1" >
289+ { message . text || message . message || message }
290+ </ Typography >
291+ </ Paper >
292+ ) ) }
293+ </ Box >
294+ </ Box >
295+ ) }
296+
297+ < Box sx = { { mt : 3 , textAlign : 'center' } } >
298+ < Typography variant = "body2" color = "text.secondary" >
299+ Hotkeys: { hotkeyHints . map ( hint => `${ hint . key } - ${ hint . label } ` ) . join ( ', ' ) }
300+ </ Typography >
301+ </ Box >
302+
303+ < FormControlLabel
304+ control = {
305+ < Checkbox
306+ id = "auto-generate-web"
307+ checked = { autoGenerate }
308+ onChange = { ( e ) => setAutoGenerate ( e . target . checked ) }
309+ />
310+ }
311+ label = "Auto-generate on paste"
312+ />
313+ </ Box >
314+ </ Paper >
315+
316+ { /* Settings Modal */ }
317+ < WebSettingsModal
318+ isOpen = { showSettings }
319+ onClose = { ( ) => setShowSettings ( false ) }
320+ onConfigUpdate = { handleConfigUpdate }
321+ autoGenerate = { autoGenerate }
322+ setAutoGenerate = { setAutoGenerate }
323+ customButtonLabel = { customButtonLabel }
324+ setCustomButtonLabel = { setCustomButtonLabel }
325+ customActionEnabled = { customActionEnabled }
326+ setCustomActionEnabled = { setCustomActionEnabled }
327+ />
328+
329+ { /* Notification */ }
330+ < Snackbar
331+ open = { ! ! notification }
332+ autoHideDuration = { 3000 }
333+ onClose = { ( ) => setNotification ( undefined ) }
334+ message = { notification }
335+ />
336+ </ Container >
337+ </ Box >
338+ </ ThemeProvider >
339+ </ ErrorBoundary >
340+ ) ;
341+ }
342+
343+ export default WebApp ;
0 commit comments