Reubencf commited on
Commit
31d0d62
·
1 Parent(s): 5de7420
components/HuggingFaceQRGenerator.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client';
2
 
3
- import React, { useState } from 'react';
4
  import { parseHuggingFaceUrl } from '../lib/huggingface';
5
  import QRCodeWithLogo from './QRCodeWithLogo';
6
  import { saveAs } from 'file-saver';
@@ -11,7 +11,7 @@ import { Input } from '@/components/ui/input';
11
  import { Label } from '@/components/ui/label';
12
  import { Badge } from '@/components/ui/badge';
13
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
14
- import { Download, Loader2, QrCode, Twitter, Facebook, Linkedin, Sparkles, ExternalLink, CheckCircle2, MessageCircle, Mail, MoreHorizontal, ChevronLeft, Menu } from 'lucide-react';
15
 
16
  const HuggingFaceQRGenerator = () => {
17
  const [inputUrl, setInputUrl] = useState('');
@@ -19,10 +19,20 @@ const HuggingFaceQRGenerator = () => {
19
  const [loading, setLoading] = useState(false);
20
  const [error, setError] = useState('');
21
  const [qrCodeInstance, setQrCodeInstance] = useState<any>(null);
 
 
 
 
 
 
 
 
 
22
 
23
  const handleGenerate = async () => {
24
  setError('');
25
  setLoading(true);
 
26
 
27
  try {
28
  // Parse the URL to extract username and resource info
@@ -56,26 +66,55 @@ const HuggingFaceQRGenerator = () => {
56
  originalAvatarUrl: data.avatarUrl,
57
  qrValue: parsed.profileUrl
58
  });
 
59
  } catch (err: any) {
60
  setError(err.message || 'Invalid URL or username');
61
  setProfileData(null);
 
62
  } finally {
63
  setLoading(false);
64
  }
65
  };
66
 
 
 
 
 
 
 
 
 
 
 
 
67
  const handleDownload = async (format = 'png') => {
68
  if (!profileData) return;
69
 
70
  try {
71
- const cardElement = document.getElementById('qr-card');
 
72
 
73
- if (format === 'png' || format === 'card') {
74
- // Download the entire card with user info
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  const dataUrl = await htmlToImage.toPng(cardElement!, {
76
  quality: 1.0,
77
  backgroundColor: '#ffffff',
78
  pixelRatio: 2,
 
 
79
  style: {
80
  margin: '0',
81
  borderRadius: '12px'
@@ -115,6 +154,34 @@ const HuggingFaceQRGenerator = () => {
115
  }
116
  };
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  const getResourceIcon = (type: string) => {
119
  switch(type) {
120
  case 'model': return '🤖';
@@ -125,71 +192,102 @@ const HuggingFaceQRGenerator = () => {
125
  };
126
 
127
  const isValid = inputUrl.trim().length > 0
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  return (
130
- <div className="min-h-screen grid place-items-center bg-linear-to-br from-purple-50 via-pink-50 to-blue-50 dark:from-gray-900 dark:via-purple-900 dark:to-gray-900 p-4 md:p-8">
131
- <div className="w-full max-w-3xl">
132
- {/* Screenshot-1 inspired input card */}
133
- <Card className="shadow-xl" style={{ fontFamily: 'var(--font-inter)' }}>
134
- <CardHeader className="pb-2">
135
- <div className="flex items-center gap-2">
136
- <span className="text-3xl">🤗</span>
137
- <CardTitle className="text-2xl">Hugging Face</CardTitle>
138
- </div>
139
- </CardHeader>
140
- <CardContent className="space-y-5">
141
- <div className="space-y-2">
142
- <Label>HUGGING FACE USERNAME</Label>
143
- <div className="relative">
144
- <div className="flex items-stretch">
145
- <span className="hidden sm:flex items-center px-3 text-sm text-muted-foreground bg-secondary/60 border border-input rounded-l-md">
146
- https://huggingface.co/
147
- </span>
148
- <Input
149
- type="text"
150
- value={inputUrl}
151
- onChange={(e) => setInputUrl(e.target.value)}
152
- placeholder="username or full URL"
153
- className="h-11 sm:rounded-l-none sm:border-l-0 pr-10"
154
- onKeyPress={(e) => e.key === 'Enter' && handleGenerate()}
155
- disabled={loading}
156
- />
 
 
 
 
 
 
 
 
 
 
 
157
  </div>
158
- <CheckCircle2
159
- className={`absolute right-3 top-1/2 -translate-y-1/2 ${isValid ? 'text-emerald-500' : 'text-muted-foreground/30'}`}
160
- />
161
- </div>
162
- </div>
163
 
164
- <Button
165
- onClick={handleGenerate}
166
- disabled={!inputUrl || loading}
167
- className="rounded-full px-6 bg-muted text-foreground hover:bg-muted/80 dark:bg-input/40"
168
- >
169
- {loading ? 'Generating…' : 'Generate QR Code'}
170
- </Button>
 
171
 
172
- {error && (
173
- <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-2 rounded-lg text-sm">
174
- {error}
175
- </div>
176
- )}
177
- </CardContent>
178
- </Card>
179
-
180
- {/* Result Section - Phone-like preview matching screenshot */}
181
- {profileData && (
182
- <div className="qr-preview flex justify-center">
183
- <div className="qr-phone-bg">
184
- <div className="qr-topbar">
185
- <button aria-label="Back"><ChevronLeft size={18} /></button>
186
- <button aria-label="Menu"><Menu size={18} /></button>
187
- </div>
188
 
189
- <div className="qr-card-v2" id="qr-card">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  <div className="qr-avatar-wrap">
191
  <img
192
- src={profileData.originalAvatarUrl || profileData.avatarUrl}
193
  alt={profileData.fullName}
194
  className="qr-avatar"
195
  crossOrigin="anonymous"
@@ -207,42 +305,46 @@ const HuggingFaceQRGenerator = () => {
207
  dotsColor="#000000"
208
  />
209
  </div>
210
- <p className="qr-caption">Share your QR code so others can follow you</p>
 
 
 
 
 
 
 
211
  <div className="qr-brand">
212
  <img src="https://huggingface.co/front/assets/huggingface_logo.svg" alt="Hugging Face" />
213
  <span>Hugging Face</span>
214
  </div>
215
  </div>
216
  </div>
217
-
218
- <div className="qr-bg-help">Tap background to change color</div>
219
-
220
- <div className="qr-share-sheet">
221
- <div className="qr-download">
222
- <button onClick={() => handleDownload('card')} className="qr-circle">
223
- <Download size={18} />
224
- </button>
225
- <span>Download</span>
 
 
 
 
 
 
226
  </div>
227
- <div className="qr-share-group">
228
- <span className="qr-share-label">Share to</span>
229
- <div className="qr-share-actions">
230
- <button className="qr-circle"><MessageCircle size={18} /></button>
231
- <button className="qr-circle"><Mail size={18} /></button>
232
- <button className="qr-circle"><MoreHorizontal size={18} /></button>
233
- </div>
234
- <div className="qr-share-texts">
235
- <span>SMS</span>
236
- <span>Email</span>
237
- <span>Other</span>
238
- </div>
239
  </div>
240
- <button className="qr-close" aria-label="Close">×</button>
241
  </div>
242
  </div>
243
  </div>
244
- )}
245
- </div>
246
  </div>
247
  );
248
  };
 
1
  'use client';
2
 
3
+ import React, { useRef, useState } from 'react';
4
  import { parseHuggingFaceUrl } from '../lib/huggingface';
5
  import QRCodeWithLogo from './QRCodeWithLogo';
6
  import { saveAs } from 'file-saver';
 
11
  import { Label } from '@/components/ui/label';
12
  import { Badge } from '@/components/ui/badge';
13
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
14
+ import { Download, Twitter, Facebook, Linkedin, ChevronLeft } from 'lucide-react';
15
 
16
  const HuggingFaceQRGenerator = () => {
17
  const [inputUrl, setInputUrl] = useState('');
 
19
  const [loading, setLoading] = useState(false);
20
  const [error, setError] = useState('');
21
  const [qrCodeInstance, setQrCodeInstance] = useState<any>(null);
22
+ const [showQR, setShowQR] = useState(false);
23
+ const [gradientIndex, setGradientIndex] = useState(0);
24
+ const gradients = [
25
+ ['#f1c40f', '#f39c12'],
26
+ ['#34d399', '#10b981'],
27
+ ['#60a5fa', '#6366f1'],
28
+ ['#fb7185', '#f472b6'],
29
+ ['#f59e0b', '#ef4444']
30
+ ];
31
 
32
  const handleGenerate = async () => {
33
  setError('');
34
  setLoading(true);
35
+ setShowQR(false);
36
 
37
  try {
38
  // Parse the URL to extract username and resource info
 
66
  originalAvatarUrl: data.avatarUrl,
67
  qrValue: parsed.profileUrl
68
  });
69
+ setShowQR(true);
70
  } catch (err: any) {
71
  setError(err.message || 'Invalid URL or username');
72
  setProfileData(null);
73
+ setShowQR(false);
74
  } finally {
75
  setLoading(false);
76
  }
77
  };
78
 
79
+ const handleBack = () => {
80
+ setShowQR(false);
81
+ };
82
+
83
+ const handleCycleBackground = () => {
84
+ setGradientIndex((prev) => (prev + 1) % gradients.length);
85
+ };
86
+
87
+ const phoneRef = useRef<HTMLDivElement | null>(null);
88
+ const cardRef = useRef<HTMLDivElement | null>(null);
89
+
90
  const handleDownload = async (format = 'png') => {
91
  if (!profileData) return;
92
 
93
  try {
94
+ const cardElement = cardRef.current || document.getElementById('qr-card');
95
+ const phoneElement = phoneRef.current;
96
 
97
+ if (format === 'full' && phoneElement) {
98
+ const rect = phoneElement.getBoundingClientRect();
99
+ const dataUrl = await htmlToImage.toPng(phoneElement, {
100
+ quality: 1.0,
101
+ pixelRatio: 2,
102
+ width: Math.ceil(rect.width),
103
+ height: Math.ceil(rect.height),
104
+ style: { margin: '0' }
105
+ });
106
+ const response = await fetch(dataUrl);
107
+ const blob = await response.blob();
108
+ saveAs(blob, `huggingface-${profileData.username}-phone.png`);
109
+ } else if (format === 'png' || format === 'card') {
110
+ // Download just the inner card
111
+ const rect = cardElement!.getBoundingClientRect();
112
  const dataUrl = await htmlToImage.toPng(cardElement!, {
113
  quality: 1.0,
114
  backgroundColor: '#ffffff',
115
  pixelRatio: 2,
116
+ width: Math.ceil(rect.width),
117
+ height: Math.ceil(rect.height),
118
  style: {
119
  margin: '0',
120
  borderRadius: '12px'
 
154
  }
155
  };
156
 
157
+ const handleShareSms = () => {
158
+ const text = encodeURIComponent(`Check out my Hugging Face profile: ${profileData?.qrValue || ''}`);
159
+ window.open(`sms:?&body=${text}`, '_blank');
160
+ };
161
+
162
+ const handleShareEmail = () => {
163
+ const subject = encodeURIComponent('My Hugging Face Profile');
164
+ const body = encodeURIComponent(`Hey! Check out my Hugging Face profile: ${profileData?.qrValue || ''}`);
165
+ window.open(`mailto:?subject=${subject}&body=${body}`, '_self');
166
+ };
167
+
168
+ const handleShareNative = async () => {
169
+ try {
170
+ if (navigator.share) {
171
+ await navigator.share({
172
+ title: 'Hugging Face Profile',
173
+ text: 'Check out my Hugging Face profile!',
174
+ url: profileData?.qrValue || ''
175
+ });
176
+ } else {
177
+ await navigator.clipboard.writeText(profileData?.qrValue || '');
178
+ alert('Link copied to clipboard');
179
+ }
180
+ } catch (e) {
181
+ // ignore cancel
182
+ }
183
+ };
184
+
185
  const getResourceIcon = (type: string) => {
186
  switch(type) {
187
  case 'model': return '🤖';
 
192
  };
193
 
194
  const isValid = inputUrl.trim().length > 0
195
+
196
+ // Validate HuggingFace username format
197
+ const validateInput = (input: string): boolean => {
198
+ if (!input.trim()) return true; // empty is neutral
199
+ // Check if it's a valid username (alphanumeric, hyphens, underscores)
200
+ // or a valid HuggingFace URL
201
+ const usernamePattern = /^[a-zA-Z0-9_-]+$/;
202
+ const urlPattern = /huggingface\.co/i;
203
+
204
+ return usernamePattern.test(input) || urlPattern.test(input);
205
+ };
206
+
207
+ const inputIsInvalid = inputUrl.trim().length > 0 && !validateInput(inputUrl);
208
 
209
  return (
210
+ <div className="min-h-screen bg-linear-to-br from-purple-50 via-pink-50 to-blue-50 dark:from-gray-900 dark:via-purple-900 dark:to-gray-900">
211
+ {/* Input form - hidden when QR is shown */}
212
+ {!showQR && (
213
+ <div className="min-h-screen grid place-items-center p-6 md:p-10">
214
+ <div className="w-full max-w-2xl mx-auto">
215
+ {/* Input card */}
216
+ <Card className="shadow-xl">
217
+ <CardHeader className="pb-4">
218
+ <div className="flex items-center gap-2">
219
+ <span className="text-3xl">🤗</span>
220
+ <div>
221
+ <CardTitle className="text-2xl font-bold tracking-tight">Hugging Face</CardTitle>
222
+ <CardDescription className="mt-1 text-muted-foreground">Generate a clean QR code for any Hugging Face profile or resource.</CardDescription>
223
+ </div>
224
+ </div>
225
+ </CardHeader>
226
+ <CardContent className="p-6 md:p-8 space-y-7">
227
+ <div className="space-y-3">
228
+ <Label className="text-xs tracking-wider font-medium">HUGGING FACE USERNAME</Label>
229
+ <div className="relative">
230
+ <div className="flex items-stretch overflow-hidden rounded-md border">
231
+ <span className="hidden sm:inline-flex items-center px-3 text-sm text-muted-foreground bg-secondary/40 select-none">
232
+ https://huggingface.co/
233
+ </span>
234
+ <Input
235
+ type="text"
236
+ value={inputUrl}
237
+ onChange={(e) => setInputUrl(e.target.value)}
238
+ placeholder="username or full URL"
239
+ className="h-11 border-0 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0"
240
+ onKeyPress={(e) => e.key === 'Enter' && handleGenerate()}
241
+ disabled={loading}
242
+ aria-invalid={inputIsInvalid}
243
+ aria-describedby="hf-input-help"
244
+ />
245
+ </div>
246
+ </div>
247
+ <p id="hf-input-help" className="text-xs text-muted-foreground">Paste a full URL or just the username, e.g. <span className="font-mono">reubencf</span>.</p>
248
  </div>
 
 
 
 
 
249
 
250
+ <Button
251
+ onClick={handleGenerate}
252
+ disabled={!inputUrl || loading}
253
+ size="lg"
254
+ className="rounded-md w-full sm:w-auto"
255
+ >
256
+ {loading ? 'Generating…' : 'Generate QR Code'}
257
+ </Button>
258
 
259
+ {error && (
260
+ <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-2 rounded-lg text-sm">
261
+ {error}
262
+ </div>
263
+ )}
264
+ </CardContent>
265
+ </Card>
266
+ </div>
267
+ </div>
268
+ )}
 
 
 
 
 
 
269
 
270
+ {/* Full screen QR preview - shown after successful generation */}
271
+ {showQR && profileData && (
272
+ <div
273
+ className="fixed inset-0 bg-linear-to-br from-purple-50 via-pink-50 to-blue-50 dark:from-gray-900 dark:via-purple-900 dark:to-gray-900 z-50 p-4 md:p-6 overflow-y-auto"
274
+ style={{ background: `linear-gradient(135deg, ${gradients[gradientIndex][0]}, ${gradients[gradientIndex][1]})` }}
275
+ >
276
+ {/* Back button moved outside of the phone so it won't appear in exports */}
277
+ <div className="qr-topbar">
278
+ <button onClick={handleBack} aria-label="Back"><ChevronLeft size={18} /></button>
279
+ </div>
280
+ <div className="qr-preview mx-auto flex justify-center py-6">
281
+ <div
282
+ className="qr-phone-bg"
283
+ ref={phoneRef}
284
+ style={{ background: `linear-gradient(135deg, ${gradients[gradientIndex][0]}, ${gradients[gradientIndex][1]})` }}
285
+ onClick={handleCycleBackground}
286
+ >
287
+ <div className="qr-card-v2" id="qr-card" ref={cardRef} onClick={(e) => e.stopPropagation()}>
288
  <div className="qr-avatar-wrap">
289
  <img
290
+ src={profileData.avatarUrl}
291
  alt={profileData.fullName}
292
  className="qr-avatar"
293
  crossOrigin="anonymous"
 
305
  dotsColor="#000000"
306
  />
307
  </div>
308
+ <p className="qr-caption">{(() => {
309
+ const t = profileData?.resourceType || profileData?.type;
310
+ if (t === 'model') return 'Scan to open this model on Hugging Face';
311
+ if (t === 'dataset') return 'Scan to open this dataset on Hugging Face';
312
+ if (t === 'space') return 'Scan to open this Space on Hugging Face';
313
+ if (profileData?.fullName) return `Scan to open ${profileData.fullName} on Hugging Face`;
314
+ return 'Scan to open on Hugging Face';
315
+ })()}</p>
316
  <div className="qr-brand">
317
  <img src="https://huggingface.co/front/assets/huggingface_logo.svg" alt="Hugging Face" />
318
  <span>Hugging Face</span>
319
  </div>
320
  </div>
321
  </div>
322
+ </div>
323
+ {/* Share sheet placed below the phone (not part of exported element) */}
324
+ <div className="qr-share-sheet" onClick={(e) => e.stopPropagation()}>
325
+ <div className="qr-download">
326
+ <button onClick={() => handleDownload('full')} className="qr-circle">
327
+ <Download size={18} />
328
+ </button>
329
+ <span>Download</span>
330
+ </div>
331
+ <div className="qr-share-group">
332
+ <span className="qr-share-label">Share to</span>
333
+ <div className="qr-share-actions">
334
+ <button className="qr-circle" onClick={() => handleShare('linkedin')} aria-label="Share on LinkedIn"><Linkedin size={18} /></button>
335
+ <button className="qr-circle" onClick={() => handleShare('facebook')} aria-label="Share on Facebook"><Facebook size={18} /></button>
336
+ <button className="qr-circle" onClick={() => handleShare('twitter')} aria-label="Share on X (Twitter)"><Twitter size={18} /></button>
337
  </div>
338
+ <div className="qr-share-texts">
339
+ <span>LinkedIn</span>
340
+ <span>Facebook</span>
341
+ <span>X</span>
 
 
 
 
 
 
 
 
342
  </div>
 
343
  </div>
344
  </div>
345
  </div>
346
+ </div>
347
+ )}
348
  </div>
349
  );
350
  };
styles/qr-generator.css CHANGED
@@ -487,14 +487,15 @@
487
  .hf-cta:disabled { opacity: .6; cursor: not-allowed; }
488
 
489
  /* Download card layout (screenshot 2) */
490
- .qr-preview { display:flex; justify-content:center; margin: 1.5rem 0 0.5rem; }
491
  .qr-phone-bg {
492
  position: relative;
493
  width: 380px;
494
  max-width: 100%;
 
495
  border-radius: 32px;
496
  background: linear-gradient(135deg,#f1c40f,#f39c12);
497
- padding: 52px 18px 26px;
498
  box-shadow: 0 12px 28px rgba(0,0,0,.15);
499
  }
500
 
@@ -528,7 +529,7 @@
528
  .qr-bg-help { text-align:center; color:#fef3c7; font-size: .78rem; margin-top: 10px; }
529
 
530
  @media (max-width: 480px) {
531
- .qr-phone-bg { padding: 48px 14px 22px; border-radius: 28px; }
532
  .qr-card-v2 { padding: 52px 14px 18px; border-radius: 20px; }
533
  }
534
 
@@ -547,19 +548,19 @@
547
 
548
  /* Bottom share sheet */
549
  .qr-share-sheet {
550
- position: absolute;
551
- left: 8px;
552
- right: 8px;
553
- bottom: 4px;
554
  background: #fff;
555
- border-top-left-radius: 18px;
556
- border-top-right-radius: 18px;
557
  box-shadow: 0 -8px 24px rgba(0,0,0,.22);
558
  display: grid;
559
  grid-template-columns: 110px 1fr;
560
  align-items: center;
561
  gap: 8px 12px;
562
  padding: 10px 12px 12px;
 
563
  }
564
 
565
  .qr-share-sheet .qr-close {
@@ -577,8 +578,9 @@
577
  .qr-circle {
578
  width: 36px; height: 36px; border-radius: 9999px; display:flex; align-items:center; justify-content:center;
579
  background: #f3f4f6; border: 1px solid #e5e7eb; color:#374151;
 
580
  }
581
- .qr-share-group { display:flex; flex-direction: column; gap: 6px; }
582
- .qr-share-label { font-size: 12px; color:#6b7280; margin-left: 8px; }
583
- .qr-share-actions { display:flex; gap: 14px; align-items:center; }
584
- .qr-share-texts { display:flex; gap: 18px; color:#6b7280; font-size: 12px; margin-left: 6px; }
 
487
  .hf-cta:disabled { opacity: .6; cursor: not-allowed; }
488
 
489
  /* Download card layout (screenshot 2) */
490
+ .qr-preview { display:flex; flex-direction: column; align-items: center; justify-content:center; margin: 1.5rem 0 0.5rem; }
491
  .qr-phone-bg {
492
  position: relative;
493
  width: 380px;
494
  max-width: 100%;
495
+ height: auto;
496
  border-radius: 32px;
497
  background: linear-gradient(135deg,#f1c40f,#f39c12);
498
+ padding: 40px 18px 18px;
499
  box-shadow: 0 12px 28px rgba(0,0,0,.15);
500
  }
501
 
 
529
  .qr-bg-help { text-align:center; color:#fef3c7; font-size: .78rem; margin-top: 10px; }
530
 
531
  @media (max-width: 480px) {
532
+ .qr-phone-bg { padding: 44px 14px 16px; border-radius: 28px; height: auto; }
533
  .qr-card-v2 { padding: 52px 14px 18px; border-radius: 20px; }
534
  }
535
 
 
548
 
549
  /* Bottom share sheet */
550
  .qr-share-sheet {
551
+ position: relative;
552
+ width: 380px;
553
+ max-width: 100%;
554
+ margin: 10px auto 0;
555
  background: #fff;
556
+ border-radius: 18px;
 
557
  box-shadow: 0 -8px 24px rgba(0,0,0,.22);
558
  display: grid;
559
  grid-template-columns: 110px 1fr;
560
  align-items: center;
561
  gap: 8px 12px;
562
  padding: 10px 12px 12px;
563
+ font-family: var(--font-inter), var(--font-geist-sans), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
564
  }
565
 
566
  .qr-share-sheet .qr-close {
 
578
  .qr-circle {
579
  width: 36px; height: 36px; border-radius: 9999px; display:flex; align-items:center; justify-content:center;
580
  background: #f3f4f6; border: 1px solid #e5e7eb; color:#374151;
581
+ cursor: pointer;
582
  }
583
+ .qr-share-group { display:grid; grid-template-rows: auto auto auto; gap: 6px; justify-items: center; }
584
+ .qr-share-label { font-size: 12px; color:#6b7280; margin-left: 0; text-align: center; letter-spacing: .02em; }
585
+ .qr-share-actions { display:grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-items:center; justify-items: center; }
586
+ .qr-share-texts { display:grid; grid-template-columns: repeat(3, 1fr); gap: 12px; color:#6b7280; font-size: 12px; text-align: center; }