1- import { useState } from "react" ;
1+ import { useState , useRef , useEffect , useCallback } from "react" ;
22import { Input } from "@/components/ui/input" ;
33import { Button } from "@/components/ui/button" ;
4- import { Info , Download , Check } from "lucide-react" ;
4+ import { Label } from "@/components/ui/label" ;
5+ import { Info , Download , Check , Upload , X } from "lucide-react" ;
56import QRCode from "qrcode" ;
67
8+ // LinKU 로고 (public/assets/icon128.png) - 고해상도 사용
9+ const LINKU_LOGO_URL = "/assets/icon128.png" ;
10+
11+ type LogoOption = "none" | "linku" | "custom" ;
12+
713const QRGeneratorSection = ( ) => {
814 const [ inputUrl , setInputUrl ] = useState < string > ( "" ) ;
15+ const [ activeUrl , setActiveUrl ] = useState < string > ( "" ) ;
916 const [ qrDataUrl , setQrDataUrl ] = useState < string > ( "" ) ;
1017 const [ error , setError ] = useState < string > ( "" ) ;
1118 const [ isGenerating , setIsGenerating ] = useState < boolean > ( false ) ;
1219
13- const generateQR = async ( ) => {
20+ // 로고 관련 상태
21+ const [ logoOption , setLogoOption ] = useState < LogoOption > ( "linku" ) ;
22+ const [ customLogoUrl , setCustomLogoUrl ] = useState < string > ( "" ) ;
23+ const fileInputRef = useRef < HTMLInputElement > ( null ) ;
24+
25+ // 이미지 로드 헬퍼
26+ const loadImage = ( src : string ) : Promise < HTMLImageElement > => {
27+ return new Promise ( ( resolve , reject ) => {
28+ const img = new Image ( ) ;
29+ img . crossOrigin = "anonymous" ;
30+ img . onload = ( ) => resolve ( img ) ;
31+ img . onerror = reject ;
32+ img . src = src ;
33+ } ) ;
34+ } ;
35+
36+ // QR 코드에 로고 오버레이
37+ const generateQRWithLogo = useCallback (
38+ async ( url : string , logoSrc : string | null ) : Promise < string > => {
39+ const canvas = document . createElement ( "canvas" ) ;
40+ // 고해상도를 위해 크기 증가 (화면에는 200px로 표시, 실제 캔버스는 400px)
41+ const size = 400 ;
42+
43+ // QR 코드를 캔버스에 그리기
44+ await QRCode . toCanvas ( canvas , url , {
45+ width : size ,
46+ margin : 2 ,
47+ errorCorrectionLevel : logoSrc ? "H" : "M" , // 로고가 있으면 높은 에러 정정
48+ color : {
49+ dark : "#000000" ,
50+ light : "#FFFFFF" ,
51+ } ,
52+ } ) ;
53+
54+ // 로고가 있으면 중앙에 그리기
55+ if ( logoSrc ) {
56+ try {
57+ const ctx = canvas . getContext ( "2d" ) ;
58+ if ( ctx ) {
59+ const logo = await loadImage ( logoSrc ) ;
60+ const logoSize = size * 0.22 ; // QR 크기의 22%
61+ const position = ( size - logoSize ) / 2 ;
62+
63+ // 로고 배경 (흰색 원형)
64+ ctx . beginPath ( ) ;
65+ ctx . arc ( size / 2 , size / 2 , logoSize / 2 + 6 , 0 , Math . PI * 2 ) ;
66+ ctx . fillStyle = "#FFFFFF" ;
67+ ctx . fill ( ) ;
68+
69+ // 로고 그리기
70+ ctx . drawImage ( logo , position , position , logoSize , logoSize ) ;
71+ }
72+ } catch {
73+ console . warn ( "로고 로드 실패, 로고 없이 생성" ) ;
74+ }
75+ }
76+
77+ return canvas . toDataURL ( "image/png" ) ;
78+ } ,
79+ [ ]
80+ ) ;
81+
82+ // URL 확정 후 QR 생성
83+ const generateQR = useCallback ( async ( ) => {
1484 if ( ! inputUrl . trim ( ) ) {
1585 setQrDataUrl ( "" ) ;
86+ setActiveUrl ( "" ) ;
1687 setError ( "" ) ;
1788 return ;
1889 }
@@ -26,25 +97,38 @@ const QRGeneratorSection = () => {
2697 return ;
2798 }
2899
29- setIsGenerating ( true ) ;
30- try {
31- const dataUrl = await QRCode . toDataURL ( inputUrl , {
32- width : 200 ,
33- margin : 2 ,
34- color : {
35- dark : "#000000" ,
36- light : "#FFFFFF" ,
37- } ,
38- } ) ;
39- setQrDataUrl ( dataUrl ) ;
40- setError ( "" ) ;
41- } catch {
42- setError ( "QR 코드 생성에 실패했습니다" ) ;
43- setQrDataUrl ( "" ) ;
44- } finally {
45- setIsGenerating ( false ) ;
46- }
47- } ;
100+ setActiveUrl ( inputUrl ) ;
101+ setError ( "" ) ;
102+ } , [ inputUrl ] ) ;
103+
104+ // activeUrl 또는 logoOption 변경 시 QR 코드 재생성
105+ useEffect ( ( ) => {
106+ if ( ! activeUrl ) return ;
107+
108+ const regenerate = async ( ) => {
109+ setIsGenerating ( true ) ;
110+ try {
111+ let logoSrc : string | null = null ;
112+
113+ if ( logoOption === "linku" ) {
114+ logoSrc = LINKU_LOGO_URL ;
115+ } else if ( logoOption === "custom" && customLogoUrl ) {
116+ logoSrc = customLogoUrl ;
117+ }
118+
119+ const dataUrl = await generateQRWithLogo ( activeUrl , logoSrc ) ;
120+ setQrDataUrl ( dataUrl ) ;
121+ setError ( "" ) ;
122+ } catch {
123+ setError ( "QR 코드 생성에 실패했습니다" ) ;
124+ setQrDataUrl ( "" ) ;
125+ } finally {
126+ setIsGenerating ( false ) ;
127+ }
128+ } ;
129+
130+ regenerate ( ) ;
131+ } , [ activeUrl , logoOption , customLogoUrl , generateQRWithLogo ] ) ;
48132
49133 const handleKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
50134 if ( e . key === "Enter" ) {
@@ -63,6 +147,34 @@ const QRGeneratorSection = () => {
63147 document . body . removeChild ( link ) ;
64148 } ;
65149
150+ const handleFileUpload = ( e : React . ChangeEvent < HTMLInputElement > ) => {
151+ const file = e . target . files ?. [ 0 ] ;
152+ if ( ! file ) return ;
153+
154+ // 이미지 파일만 허용
155+ if ( ! file . type . startsWith ( "image/" ) ) {
156+ setError ( "이미지 파일만 업로드 가능합니다" ) ;
157+ return ;
158+ }
159+
160+ // FileReader로 Data URL 생성
161+ const reader = new FileReader ( ) ;
162+ reader . onload = ( event ) => {
163+ const dataUrl = event . target ?. result as string ;
164+ setCustomLogoUrl ( dataUrl ) ;
165+ setLogoOption ( "custom" ) ;
166+ } ;
167+ reader . readAsDataURL ( file ) ;
168+ } ;
169+
170+ const handleRemoveCustomLogo = ( ) => {
171+ setCustomLogoUrl ( "" ) ;
172+ setLogoOption ( "linku" ) ;
173+ if ( fileInputRef . current ) {
174+ fileInputRef . current . value = "" ;
175+ }
176+ } ;
177+
66178 return (
67179 < div className = "space-y-4 pt-4 mt-4 border-t" >
68180 < h2 className = "text-base font-semibold" > QR 코드 생성</ h2 >
@@ -94,14 +206,75 @@ const QRGeneratorSection = () => {
94206 </ Button >
95207 </ div >
96208
209+ { /* 로고 옵션 */ }
210+ < div className = "space-y-2" >
211+ < Label className = "text-xs text-muted-foreground" > 중앙 로고</ Label >
212+ < div className = "flex items-center gap-2" >
213+ < Button
214+ variant = { logoOption === "none" ? "default" : "outline" }
215+ size = "sm"
216+ onClick = { ( ) => setLogoOption ( "none" ) }
217+ className = "text-xs h-7"
218+ >
219+ 없음
220+ </ Button >
221+ < Button
222+ variant = { logoOption === "linku" ? "default" : "outline" }
223+ size = "sm"
224+ onClick = { ( ) => setLogoOption ( "linku" ) }
225+ className = "text-xs h-7"
226+ >
227+ LinKU
228+ </ Button >
229+ < Button
230+ variant = { logoOption === "custom" ? "default" : "outline" }
231+ size = "sm"
232+ onClick = { ( ) => fileInputRef . current ?. click ( ) }
233+ className = "text-xs h-7"
234+ >
235+ < Upload className = "h-3 w-3 mr-1" />
236+ 업로드
237+ </ Button >
238+ < input
239+ ref = { fileInputRef }
240+ type = "file"
241+ accept = "image/*"
242+ onChange = { handleFileUpload }
243+ className = "hidden"
244+ />
245+ </ div >
246+
247+ { /* 커스텀 로고 미리보기 */ }
248+ { logoOption === "custom" && customLogoUrl && (
249+ < div className = "flex items-center gap-2 p-2 bg-muted/30 rounded" >
250+ < img
251+ src = { customLogoUrl }
252+ alt = "Custom logo"
253+ className = "h-8 w-8 object-contain rounded"
254+ />
255+ < span className = "text-xs text-muted-foreground flex-1" >
256+ 커스텀 로고
257+ </ span >
258+ < Button
259+ variant = "ghost"
260+ size = "icon"
261+ className = "h-6 w-6"
262+ onClick = { handleRemoveCustomLogo }
263+ >
264+ < X className = "h-3 w-3" />
265+ </ Button >
266+ </ div >
267+ ) }
268+ </ div >
269+
97270 { error && < p className = "text-xs text-red-500" > { error } </ p > }
98271
99272 { qrDataUrl && (
100273 < div className = "flex flex-col items-center gap-3 py-4" >
101274 < img
102275 src = { qrDataUrl }
103276 alt = "QR Code"
104- className = "border rounded-lg shadow-sm"
277+ className = "border rounded-lg shadow-sm w-[200px] h-[200px] "
105278 />
106279 < Button variant = "outline" size = "sm" onClick = { handleDownload } >
107280 < Download className = "h-4 w-4 mr-2" />
0 commit comments