22
33import React , { useState , useEffect , useRef } from "react"
44import { useRouter } from "next/navigation"
5- import { Sun , Moon , Star , Heart , Flame , Cat , Snowflake , Send , Check , User , Settings2 , LogOut , LogIn , X , Sparkles , Paperclip , Sunrise } from "lucide-react"
5+ import { Sun , Moon , Star , Heart , Flame , Cat , Snowflake , Send , Check , User , Settings2 , LogOut , LogIn , X , Sparkles , Paperclip , Sunrise , FileText , File , Loader2 } from "lucide-react"
66import SettingsModal from "@/components/SettingsModal"
77import { motion , AnimatePresence } from "framer-motion"
88import { THEMES } from "@/types"
@@ -12,6 +12,7 @@ import { useSession, signIn, signOut } from "next-auth/react"
1212import { shouldShowLoginGate , savePendingDebate } from "@/components/LoginGate"
1313import LoginGateModal from "@/components/LoginGate"
1414import { timeAgo } from "@/lib/time"
15+ import { parseFile , SUPPORTED_EXTENSIONS } from "@/lib/file-parser"
1516
1617/* ─── Model SVG Icons ─── */
1718
@@ -80,6 +81,9 @@ const t = {
8081 roundsCount : "Rounds" ,
8182 models : "Participants" ,
8283 keyboardHint : "to submit" ,
84+ attach : "Attach file" ,
85+ parsing : "Reading files..." ,
86+ unsupported : "Supported: PDF, DOCX, Excel, and text files" ,
8387 settings : "Settings" ,
8488 signOut : "Sign Out" ,
8589 tooltips : {
@@ -111,6 +115,9 @@ const t = {
111115 roundsCount : "라운드 수" ,
112116 models : "참여 모델" ,
113117 keyboardHint : "눌러서 시작" ,
118+ attach : "파일 첨부" ,
119+ parsing : "파일 읽는 중..." ,
120+ unsupported : "지원 형식: PDF, DOCX, Excel, 텍스트 파일" ,
114121 settings : "설정" ,
115122 signOut : "로그아웃" ,
116123 tooltips : {
@@ -141,25 +148,26 @@ function modelDisplayName(id: Provider): string {
141148export default function Home ( ) {
142149 const router = useRouter ( )
143150 const [ theme , setTheme ] = useState < Theme > ( "dark" )
144- const [ locale , setLocale ] = useState < Locale > ( ( ) => {
145- if ( typeof window === "undefined" ) return "ko"
146- const saved = localStorage . getItem ( "quorum_locale" )
147- return saved === "en" || saved === "ko" ? saved : "ko"
148- } )
151+ const [ locale , setLocale ] = useState < Locale > ( "ko" )
149152 const [ prompt , setPrompt ] = useState ( "" )
150153 const [ selectedModels , setSelectedModels ] = useState < Provider [ ] > ( [ "gemini" , "perplexity" , "claude" , "gpt" ] )
151- const [ responseLength , setResponseLength ] = useState < ResponseLength > ( ( ) => {
152- if ( typeof window === "undefined" ) return "short"
153- const saved = localStorage . getItem ( "quorum_responseLength" )
154- return saved === "short" || saved === "medium" || saved === "long" ? saved : "short"
155- } )
156- const [ rounds , setRounds ] = useState < number > ( ( ) => {
157- if ( typeof window === "undefined" ) return 1
158- const saved = localStorage . getItem ( "quorum_rounds" )
159- if ( saved ) { const n = parseInt ( saved , 10 ) ; if ( [ 1 , 2 , 3 , 5 ] . includes ( n ) ) return n }
160- return 1
161- } )
154+ const [ responseLength , setResponseLength ] = useState < ResponseLength > ( "short" )
155+ const [ rounds , setRounds ] = useState < number > ( 1 )
156+
157+ // Hydrate persisted settings from localStorage after mount
158+ useEffect ( ( ) => {
159+ const savedLocale = localStorage . getItem ( "quorum_locale" )
160+ if ( savedLocale === "en" || savedLocale === "ko" ) setLocale ( savedLocale )
161+ const savedLength = localStorage . getItem ( "quorum_responseLength" )
162+ if ( savedLength === "short" || savedLength === "medium" || savedLength === "long" ) setResponseLength ( savedLength )
163+ const savedRounds = localStorage . getItem ( "quorum_rounds" )
164+ if ( savedRounds ) { const n = parseInt ( savedRounds , 10 ) ; if ( [ 1 , 2 , 3 , 5 ] . includes ( n ) ) setRounds ( n ) }
165+ } , [ ] )
162166 const [ isFocused , setIsFocused ] = useState ( false )
167+ const [ files , setFiles ] = useState < { id : string ; file : File ; preview ?: string } [ ] > ( [ ] )
168+ const [ isDragging , setIsDragging ] = useState ( false )
169+ const [ isParsing , setIsParsing ] = useState ( false )
170+ const [ fileError , setFileError ] = useState < string | null > ( null )
163171 const textareaRef = useRef < HTMLTextAreaElement > ( null )
164172 const fileInputRef = useRef < HTMLInputElement > ( null )
165173
@@ -171,8 +179,6 @@ export default function Home() {
171179 // Header & Settings state
172180 const [ showDropdown , setShowDropdown ] = useState ( false )
173181 const [ showSettings , setShowSettings ] = useState ( false )
174- const [ files , setFiles ] = useState < File [ ] > ( [ ] )
175- const [ isDragging , setIsDragging ] = useState ( false )
176182 const [ recentThreads , setRecentThreads ] = useState < ThreadSummary [ ] > ( [ ] )
177183
178184 useEffect ( ( ) => {
@@ -274,28 +280,113 @@ export default function Home() {
274280 }
275281 }
276282
277- const handleSubmit = ( ) => {
278- if ( ! prompt . trim ( ) ) return
279- if ( shouldShowLoginGate ( ! ! session ?. user ) ) {
280- savePendingDebate ( {
281- prompt : prompt . trim ( ) ,
283+ // File helpers
284+ const addFiles = ( incoming : File [ ] ) => {
285+ const supported : File [ ] = [ ]
286+ let hasUnsupported = false
287+ for ( const file of incoming ) {
288+ const ext = file . name . split ( "." ) . pop ( ) ?. toLowerCase ( ) ?? ""
289+ if ( SUPPORTED_EXTENSIONS . has ( ext ) ) {
290+ supported . push ( file )
291+ } else {
292+ hasUnsupported = true
293+ }
294+ }
295+ if ( hasUnsupported ) setFileError ( t [ locale ] . unsupported )
296+ if ( supported . length === 0 ) return
297+ setFiles ( ( prev ) => [
298+ ...prev ,
299+ ...supported . map ( ( file ) => ( {
300+ id : Math . random ( ) . toString ( 36 ) . substr ( 2 , 9 ) ,
301+ file,
302+ preview : file . type . startsWith ( "image/" ) ? URL . createObjectURL ( file ) : undefined ,
303+ } ) ) ,
304+ ] )
305+ }
306+
307+ const removeFile = ( id : string ) => {
308+ setFiles ( ( prev ) => {
309+ const removed = prev . find ( ( f ) => f . id === id )
310+ if ( removed ?. preview ) URL . revokeObjectURL ( removed . preview )
311+ return prev . filter ( ( f ) => f . id !== id )
312+ } )
313+ }
314+
315+ // Auto-dismiss file error
316+ useEffect ( ( ) => {
317+ if ( ! fileError ) return
318+ const timer = setTimeout ( ( ) => setFileError ( null ) , 3000 )
319+ return ( ) => clearTimeout ( timer )
320+ } , [ fileError ] )
321+
322+ const buildPromptWithFiles = async ( ) : Promise < string | null > => {
323+ let messageText = prompt . trim ( )
324+
325+ if ( files . length > 0 ) {
326+ const results = await Promise . allSettled (
327+ files . map ( async ( af ) => {
328+ const content = await parseFile ( af . file )
329+ if ( content && ! content . startsWith ( "[Unsupported" ) ) {
330+ return `--- File: ${ af . file . name } ---\n${ content } `
331+ }
332+ return null
333+ } )
334+ )
335+
336+ const fileContents = results
337+ . map ( ( r , i ) => {
338+ if ( r . status === "fulfilled" && r . value ) return r . value
339+ if ( r . status === "rejected" ) {
340+ console . error ( `Failed to parse ${ files [ i ] . file . name } :` , r . reason )
341+ return `--- File: ${ files [ i ] . file . name } ---\n[Error: Could not read file]`
342+ }
343+ return null
344+ } )
345+ . filter ( Boolean ) as string [ ]
346+
347+ if ( fileContents . length > 0 ) {
348+ messageText = messageText
349+ ? `${ messageText } \n\n${ fileContents . join ( "\n\n" ) } `
350+ : fileContents . join ( "\n\n" )
351+ }
352+ }
353+
354+ return messageText || null
355+ }
356+
357+ const handleSubmit = async ( ) => {
358+ if ( isParsing || ( ! prompt . trim ( ) && files . length === 0 ) ) return
359+
360+ setIsParsing ( true )
361+ try {
362+ const messageText = await buildPromptWithFiles ( )
363+ if ( ! messageText ) return
364+
365+ if ( shouldShowLoginGate ( ! ! session ?. user ) ) {
366+ savePendingDebate ( {
367+ prompt : messageText ,
368+ models : selectedModels ,
369+ responseLength,
370+ rounds,
371+ locale,
372+ } )
373+ setShowGate ( true )
374+ return
375+ }
376+
377+ const config = {
378+ prompt : messageText ,
282379 models : selectedModels ,
283380 responseLength,
284381 rounds,
285382 locale,
286- } )
287- setShowGate ( true )
288- return
289- }
290- const config = {
291- prompt : prompt . trim ( ) ,
292- models : selectedModels ,
293- responseLength,
294- rounds,
295- locale,
383+ }
384+ sessionStorage . setItem ( "quorum_config" , JSON . stringify ( config ) )
385+ files . forEach ( ( f ) => { if ( f . preview ) URL . revokeObjectURL ( f . preview ) } )
386+ router . push ( "/chat" )
387+ } finally {
388+ setIsParsing ( false )
296389 }
297- sessionStorage . setItem ( "quorum_config" , JSON . stringify ( config ) )
298- router . push ( "/chat" )
299390 }
300391
301392 const handleKeyDown = ( e : React . KeyboardEvent ) => {
@@ -305,23 +396,6 @@ export default function Home() {
305396 }
306397 }
307398
308- const handleDragOver = ( e : React . DragEvent ) => {
309- e . preventDefault ( )
310- setIsDragging ( true )
311- }
312-
313- const handleDragLeave = ( e : React . DragEvent ) => {
314- e . preventDefault ( )
315- setIsDragging ( false )
316- }
317-
318- const handleDrop = ( e : React . DragEvent ) => {
319- e . preventDefault ( )
320- setIsDragging ( false )
321- if ( e . dataTransfer . files && e . dataTransfer . files . length > 0 ) {
322- setFiles ( ( prev ) => [ ...prev , ...Array . from ( e . dataTransfer . files ) ] )
323- }
324- }
325399
326400 return (
327401 < div className = "relative min-h-screen overflow-hidden bg-background text-foreground font-[family-name:var(--font-geist-sans)] selection:bg-zinc-200 dark:selection:bg-zinc-800 transition-colors duration-300 flex flex-col" >
@@ -493,16 +567,32 @@ export default function Home() {
493567 { /* Textarea */ }
494568 < motion . div
495569 className = { `relative group rounded-3xl p-[2px] overflow-hidden -mx-4 sm:-mx-6 ${ isDragging ? "ring-4 ring-purple-500" : "" } ` }
496- onDragOver = { handleDragOver }
497- onDragLeave = { handleDragLeave }
498- onDrop = { handleDrop }
570+ onDragOver = { ( e ) => { e . preventDefault ( ) ; if ( ! isParsing ) setIsDragging ( true ) } }
571+ onDragLeave = { ( e ) => { e . preventDefault ( ) ; setIsDragging ( false ) } }
572+ onDrop = { ( e ) => {
573+ e . preventDefault ( )
574+ setIsDragging ( false )
575+ if ( ! isParsing && e . dataTransfer . files ) addFiles ( Array . from ( e . dataTransfer . files ) )
576+ } }
499577 initial = { false }
500578 animate = { { scale : isFocused ? 1.02 : 1 } }
501579 transition = { { type : "spring" , stiffness : 300 , damping : 20 } }
502580 >
503581 < div className = { `absolute inset-0 bg-[conic-gradient(from_0deg,red,purple,blue,red)] animate-rotate-border ${ isFocused ? "opacity-100" : "opacity-50" } ` } />
504582
505583 < div className = "relative bg-background rounded-[22px] p-4 sm:p-6" >
584+ < AnimatePresence >
585+ { fileError && (
586+ < motion . div
587+ initial = { { opacity : 0 , height : 0 } }
588+ animate = { { opacity : 1 , height : "auto" } }
589+ exit = { { opacity : 0 , height : 0 } }
590+ className = "mb-3 px-3 py-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800/30 rounded-xl"
591+ >
592+ { fileError }
593+ </ motion . div >
594+ ) }
595+ </ AnimatePresence >
506596 < textarea
507597 ref = { textareaRef }
508598 value = { prompt }
@@ -514,13 +604,36 @@ export default function Home() {
514604 className = "w-full bg-transparent text-lg min-[375px]:text-xl sm:text-2xl md:text-3xl lg:text-4xl font-medium tracking-tight placeholder:text-zinc-400 dark:placeholder:text-zinc-600 resize-none outline-none min-h-[100px] sm:min-h-[120px] leading-[1.15]"
515605 autoFocus
516606 />
517- < div className = "flex items-center justify-between mt-2" >
607+ { files . length > 0 && (
608+ < div className = "flex flex-wrap gap-2 mt-3" >
609+ { files . map ( ( af ) => (
610+ < div
611+ key = { af . id }
612+ className = "group/file flex items-center gap-2 px-3 py-1.5 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-full text-sm"
613+ >
614+ { af . file . type . includes ( "pdf" ) ? (
615+ < FileText className = "w-3.5 h-3.5 text-red-500 flex-shrink-0" />
616+ ) : (
617+ < File className = "w-3.5 h-3.5 text-zinc-400 flex-shrink-0" />
618+ ) }
619+ < span className = "truncate max-w-[150px] text-zinc-700 dark:text-zinc-300" > { af . file . name } </ span >
620+ < button onClick = { ( ) => removeFile ( af . id ) } className = "hover:text-red-500 transition-colors" >
621+ < X size = { 14 } />
622+ </ button >
623+ </ div >
624+ ) ) }
625+ </ div >
626+ ) }
627+ < div className = "flex items-center justify-between mt-3" >
518628 < button
519629 type = "button"
520630 onClick = { ( ) => fileInputRef . current ?. click ( ) }
521- className = "p-2 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
631+ disabled = { isParsing }
632+ title = { t [ locale ] . attach }
633+ aria-label = { t [ locale ] . attach }
634+ className = "p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-xl transition-all active:scale-95 disabled:opacity-50"
522635 >
523- < Paperclip size = { 20 } />
636+ < Paperclip className = "w-5 h-5" />
524637 </ button >
525638 < input
526639 type = "file"
@@ -529,24 +642,11 @@ export default function Home() {
529642 multiple
530643 accept = ".pdf,.docx,.xlsx,.xls,.txt,.md,.csv"
531644 onChange = { ( e ) => {
532- if ( e . target . files ) {
533- setFiles ( ( prev ) => [ ...prev , ...Array . from ( e . target . files ! ) ] )
534- }
645+ if ( e . target . files ) addFiles ( Array . from ( e . target . files ) )
646+ e . target . value = ""
535647 } }
536648 />
537649 </ div >
538- { files . length > 0 && (
539- < div className = "flex flex-wrap gap-2 mt-4" >
540- { files . map ( ( file , index ) => (
541- < div key = { index } className = "flex items-center gap-2 bg-zinc-200 dark:bg-zinc-800 px-3 py-1 rounded-full text-sm" >
542- < span className = "truncate max-w-[150px]" > { file . name } </ span >
543- < button onClick = { ( ) => setFiles ( files . filter ( ( _ , i ) => i !== index ) ) } className = "hover:text-red-500" >
544- < X size = { 14 } />
545- </ button >
546- </ div >
547- ) ) }
548- </ div >
549- ) }
550650 </ div >
551651 </ motion . div >
552652
@@ -704,11 +804,20 @@ export default function Home() {
704804 { /* Submit Button */ }
705805 < button
706806 onClick = { handleSubmit }
707- disabled = { ! prompt . trim ( ) }
807+ disabled = { isParsing || ( ! prompt . trim ( ) && files . length === 0 ) }
708808 className = "cursor-pointer w-full lg:w-auto group relative flex items-center justify-center gap-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 px-6 py-4 sm:py-3.5 rounded-2xl text-sm font-medium transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-zinc-900/10 dark:shadow-zinc-100/10 mt-2 lg:mt-0"
709809 >
710- < Send size = { 16 } className = "transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
711- { t [ locale ] . start }
810+ { isParsing ? (
811+ < >
812+ < Loader2 size = { 16 } className = "animate-spin" />
813+ { t [ locale ] . parsing }
814+ </ >
815+ ) : (
816+ < >
817+ < Send size = { 16 } className = "transition-transform duration-300 group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
818+ { t [ locale ] . start }
819+ </ >
820+ ) }
712821 </ button >
713822 </ div >
714823 </ div >
0 commit comments