Skip to content

Commit 7162451

Browse files
committed
feat: enhance QRGeneratorSection with logo options and file upload functionality
1 parent 4c966f9 commit 7162451

1 file changed

Lines changed: 196 additions & 23 deletions

File tree

src/components/Labs/QRGeneratorSection.tsx

Lines changed: 196 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,89 @@
1-
import { useState } from "react";
1+
import { useState, useRef, useEffect, useCallback } from "react";
22
import { Input } from "@/components/ui/input";
33
import { 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";
56
import 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+
713
const 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

Comments
 (0)