kyd-shared-badge 0.3.121 → 0.3.122
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/connect/ConnectAccounts.tsx +437 -452
- package/src/connect/types.ts +3 -0
- package/src/ui/connect-progress.tsx +61 -8
- package/src/ui/progress-circle.tsx +27 -1
package/package.json
CHANGED
|
@@ -3,9 +3,9 @@ import { useRouter } from 'next/navigation';
|
|
|
3
3
|
import { ProviderIcon } from '../utils/provider';
|
|
4
4
|
import { normalizeLinkedInInput } from './linkedin';
|
|
5
5
|
import type { ConnectAccountsProps } from './types';
|
|
6
|
-
import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink,
|
|
7
|
-
import { AnimatePresence, motion } from 'framer-motion';
|
|
8
|
-
import { Button, Input, Spinner, Card, CardHeader, CardContent,
|
|
6
|
+
import { CheckCircle, Link2, LinkIcon, Unlink, ArrowLeft, ExternalLink, Eye, Lock, InfoIcon } from 'lucide-react';
|
|
7
|
+
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
|
8
|
+
import { Button, Input, Spinner, Card, CardHeader, CardContent, CardTitle, ConnectProgress } from '../ui';
|
|
9
9
|
import Link from 'next/link';
|
|
10
10
|
import { Tooltip, TooltipTrigger, TooltipProvider, TooltipContent } from '../ui/';
|
|
11
11
|
|
|
@@ -42,6 +42,9 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
42
42
|
githubAppSlugId,
|
|
43
43
|
userId,
|
|
44
44
|
inviteId,
|
|
45
|
+
shouldShowContinueButton,
|
|
46
|
+
shouldDisableContinueButton,
|
|
47
|
+
onContinue,
|
|
45
48
|
} = props;
|
|
46
49
|
|
|
47
50
|
const router = useRouter();
|
|
@@ -49,8 +52,9 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
49
52
|
const [linkUrl, setLinkUrl] = useState('');
|
|
50
53
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
51
54
|
const [isDisconnecting, setIsDisconnecting] = useState<string | null>(null);
|
|
52
|
-
const [showGithubManage, setShowGithubManage] = useState(false);
|
|
53
55
|
const [showDataHandling, setShowDataHandling] = useState(false);
|
|
56
|
+
const [previewProviderId, setPreviewProviderId] = useState<string | null>(null);
|
|
57
|
+
const [previewAction, setPreviewAction] = useState<'connect' | 'disconnect' | null>(null);
|
|
54
58
|
|
|
55
59
|
const apiBase = apiGatewayUrl || (typeof process !== 'undefined' ? (process.env.NEXT_PUBLIC_API_GATEWAY_URL as string) : '');
|
|
56
60
|
const connectedIds = useMemo(
|
|
@@ -63,8 +67,6 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
63
67
|
useEffect(() => {
|
|
64
68
|
if (initialProviderId && initialProviderId !== selectedProviderId) {
|
|
65
69
|
setSelectedProviderIdAndCallback(initialProviderId);
|
|
66
|
-
// If we landed here from initialProviderId, show the existing card (not manage)
|
|
67
|
-
if (initialProviderId === 'githubapp') setShowGithubManage(false);
|
|
68
70
|
}
|
|
69
71
|
// Do not clear selection if initialProviderId becomes falsy later
|
|
70
72
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -114,7 +116,6 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
114
116
|
});
|
|
115
117
|
const data = await res.json().catch(() => ({}));
|
|
116
118
|
if (!res.ok) throw new Error(data?.error || `Failed to disconnect ${providerId}.`);
|
|
117
|
-
setShowGithubManage(false);
|
|
118
119
|
if (onDisconnected) onDisconnected(providerId);
|
|
119
120
|
} catch (e) {
|
|
120
121
|
const err = e as Error;
|
|
@@ -194,10 +195,18 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
194
195
|
) || null;
|
|
195
196
|
}, [selectedProviderId, providers]);
|
|
196
197
|
|
|
198
|
+
const setPreview = (providerId: string, action: 'connect' | 'disconnect') => {
|
|
199
|
+
setPreviewProviderId(providerId.toLowerCase());
|
|
200
|
+
setPreviewAction(action);
|
|
201
|
+
};
|
|
202
|
+
const clearPreview = () => {
|
|
203
|
+
setPreviewProviderId(null);
|
|
204
|
+
setPreviewAction(null);
|
|
205
|
+
};
|
|
206
|
+
|
|
197
207
|
const handleConnectBack = () => {
|
|
198
208
|
setSelectedProviderIdAndCallback(null);
|
|
199
209
|
setLinkUrl('');
|
|
200
|
-
setShowGithubManage(false);
|
|
201
210
|
setShowDataHandling(false);
|
|
202
211
|
};
|
|
203
212
|
|
|
@@ -206,11 +215,18 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
206
215
|
animate: { opacity: 1, y: 0 },
|
|
207
216
|
exit: { opacity: 0, y: -20 },
|
|
208
217
|
};
|
|
218
|
+
const shouldReduceMotion = useReducedMotion();
|
|
219
|
+
const ease = [0.22, 1, 0.36, 1] as const;
|
|
209
220
|
const fadeOnly = {
|
|
210
221
|
initial: { opacity: 0 },
|
|
211
222
|
animate: { opacity: 1 },
|
|
212
223
|
exit: { opacity: 0 },
|
|
213
224
|
};
|
|
225
|
+
const fadeIn = {
|
|
226
|
+
hidden: { opacity: 0, y: shouldReduceMotion ? 0 : 8 },
|
|
227
|
+
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease } },
|
|
228
|
+
exit: { opacity: 0, y: shouldReduceMotion ? 0 : -4, transition: { duration: 0.2, ease } }
|
|
229
|
+
};
|
|
214
230
|
|
|
215
231
|
// GitHub status helpers
|
|
216
232
|
const githubConnectedAccount = useMemo(() => {
|
|
@@ -225,482 +241,451 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
225
241
|
// Progress UI is outsourced to ConnectProgress
|
|
226
242
|
|
|
227
243
|
return (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
Back
|
|
242
|
-
</button>
|
|
243
|
-
<div className="text-center">
|
|
244
|
-
<div className="flex justify-center mb-4">
|
|
245
|
-
<InfoIcon className="w-10 h-10 text-[var(--text-main)]" />
|
|
246
|
-
</div>
|
|
247
|
-
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
|
|
248
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-2 leading-relaxed">
|
|
249
|
-
We understand that giving access to your private repositories can be a bit scary. So here's the deal: We install the KYD GitHub App in your account. The app has
|
|
250
|
-
<span className="mx-1"><Link href="https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-contents" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>Contents <ExternalLink className="size-3 inline-block" /></Link></span>
|
|
251
|
-
read access - only to the repositories you select. Then, once you request a badge assessment, we read the repositories and analyze the code, then its deleted, forever. Your code is not accessible to anyone, not even us.
|
|
252
|
-
</p>
|
|
253
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-3 leading-relaxed">
|
|
254
|
-
For other details, see our{' '}
|
|
255
|
-
<Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
|
|
256
|
-
Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
|
|
257
|
-
</Link>.
|
|
258
|
-
</p>
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
</motion.div>
|
|
262
|
-
) : selectedProvider && selectedProvider.id !== 'githubapp' ? (
|
|
263
|
-
<motion.div
|
|
264
|
-
key="connect-card"
|
|
265
|
-
initial="initial" animate="animate" exit="exit" variants={fadeOnly}
|
|
266
|
-
className="rounded-xl border max-w-xl w-full"
|
|
267
|
-
style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
|
|
268
|
-
>
|
|
269
|
-
<div className="sm:p-6 p-4">
|
|
270
|
-
<button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
|
|
271
|
-
<ArrowLeft className="w-4 h-4" />
|
|
272
|
-
Back
|
|
273
|
-
</button>
|
|
274
|
-
<div className="text-center">
|
|
275
|
-
<div className="flex justify-center mb-4">
|
|
276
|
-
<ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
|
|
277
|
-
</div>
|
|
278
|
-
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
|
|
279
|
-
{selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
|
|
280
|
-
? `Use Public ${selectedProvider.name} Profile`
|
|
281
|
-
: `Connect ${selectedProvider.name}`}
|
|
282
|
-
</h3>
|
|
283
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
|
|
284
|
-
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
|
|
285
|
-
? (selectedProvider.placeholder || 'Enter your public profile URL.')
|
|
286
|
-
: `Authorize with ${selectedProvider.name} to connect your account.`}
|
|
287
|
-
</p>
|
|
288
|
-
</div>
|
|
289
|
-
|
|
290
|
-
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
|
|
291
|
-
<motion.form
|
|
292
|
-
onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
|
|
293
|
-
className="mt-6 space-y-4"
|
|
294
|
-
initial="initial" animate="animate" exit="exit" variants={cardVariants}
|
|
295
|
-
>
|
|
296
|
-
{selectedProvider.id === 'linkedin' && (
|
|
297
|
-
<>
|
|
298
|
-
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
299
|
-
<Link
|
|
300
|
-
href="https://www.linkedin.com/public-profile/settings"
|
|
301
|
-
target="_blank"
|
|
302
|
-
rel="noopener noreferrer"
|
|
303
|
-
className="underline"
|
|
304
|
-
style={{ color: 'var(--icon-accent)' }}
|
|
305
|
-
>
|
|
306
|
-
LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
|
|
307
|
-
</Link>
|
|
308
|
-
. This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
|
|
309
|
-
</p>
|
|
310
|
-
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
311
|
-
LinkedIn data is not used to contribute to your score.
|
|
312
|
-
</p>
|
|
313
|
-
</>
|
|
314
|
-
|
|
315
|
-
)}
|
|
316
|
-
<div className="relative">
|
|
317
|
-
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
|
|
318
|
-
<Input
|
|
319
|
-
type="url"
|
|
320
|
-
value={linkUrl}
|
|
321
|
-
onChange={(e) => setLinkUrl(e.target.value)}
|
|
322
|
-
placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
|
|
323
|
-
required
|
|
324
|
-
className="w-full border bg-transparent p-2 pl-9"
|
|
325
|
-
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
326
|
-
onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
|
|
327
|
-
onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
|
|
328
|
-
/>
|
|
329
|
-
</div>
|
|
330
|
-
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
331
|
-
<Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
|
|
332
|
-
{isSubmitting ? (
|
|
333
|
-
<div className="flex items-center justify-center">
|
|
334
|
-
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
335
|
-
Connecting...
|
|
336
|
-
</div>
|
|
337
|
-
) : (
|
|
338
|
-
'Connect'
|
|
339
|
-
)}
|
|
340
|
-
</Button>
|
|
341
|
-
</motion.div>
|
|
342
|
-
</motion.form>
|
|
343
|
-
) : (
|
|
344
|
-
<div className="mt-6">
|
|
345
|
-
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
346
|
-
<Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
|
|
347
|
-
<ExternalLink className="w-4 h-4 mr-2" />
|
|
348
|
-
Connect with {selectedProvider.name}
|
|
349
|
-
</Button>
|
|
350
|
-
</motion.div>
|
|
351
|
-
</div>
|
|
352
|
-
)}
|
|
244
|
+
<div className="w-full grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_380px] lg:justify-center gap-6 items-start max-w-7xl">
|
|
245
|
+
<div className="w-full flex justify-end">
|
|
246
|
+
<div className="w-full max-w-lg">
|
|
247
|
+
{/* Mobile: show progress above accounts, left-aligned */}
|
|
248
|
+
<div className="lg:hidden mb-4">
|
|
249
|
+
<ConnectProgress
|
|
250
|
+
layout="inline"
|
|
251
|
+
providers={providers}
|
|
252
|
+
connectedIds={connectedIds}
|
|
253
|
+
previewProviderId={previewProviderId}
|
|
254
|
+
previewAction={previewAction}
|
|
255
|
+
selectedProviderId={selectedProvider?.id || null}
|
|
256
|
+
/>
|
|
353
257
|
</div>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
<motion.div className="p-6 flex flex-col items-center" initial="initial" animate="animate" exit="exit" variants={fadeOnly}>
|
|
366
|
-
<div className="w-full flex items-center gap-3 mb-2 justify-center">
|
|
367
|
-
<ProviderIcon name="github" className="w-8 h-8 inline-block" />
|
|
368
|
-
<span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
|
|
369
|
-
</div>
|
|
370
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
|
|
371
|
-
You've successfully linked your GitHub account!
|
|
372
|
-
<br />
|
|
373
|
-
To complete your profile, you can optionally allow access to your <b>private repositories</b>. This is useful if you'd like to highlight private work or share additional contributions for verification.
|
|
374
|
-
<br /><br />
|
|
375
|
-
<span className="text-[var(--text-main)] font-medium">
|
|
376
|
-
Would you like to connect your private repositories?
|
|
377
|
-
</span>
|
|
378
|
-
<button
|
|
379
|
-
type="button"
|
|
380
|
-
onClick={() => setShowDataHandling(true)}
|
|
381
|
-
className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
|
|
382
|
-
>
|
|
383
|
-
How KYD Handles Your Data
|
|
258
|
+
{showDataHandling ? (
|
|
259
|
+
<motion.div
|
|
260
|
+
key="data-handling-card"
|
|
261
|
+
className="rounded-xl border max-w-xl w-full"
|
|
262
|
+
initial="initial" animate="animate" exit="exit" variants={fadeOnly}
|
|
263
|
+
style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
|
|
264
|
+
>
|
|
265
|
+
<div className="sm:p-6 p-4">
|
|
266
|
+
<button onClick={() => setShowDataHandling(false)} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
|
|
267
|
+
<ArrowLeft className="w-4 h-4" />
|
|
268
|
+
Back
|
|
384
269
|
</button>
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
>
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
>
|
|
403
|
-
<span className="flex items-center justify-center">
|
|
404
|
-
<ExternalLink className="w-4 h-4 mr-2" />
|
|
405
|
-
Yes, connect my private repos
|
|
406
|
-
</span>
|
|
407
|
-
</Button>
|
|
408
|
-
</motion.div>
|
|
270
|
+
<div className="text-center">
|
|
271
|
+
<div className="flex justify-center mb-4">
|
|
272
|
+
<InfoIcon className="w-10 h-10 text-[var(--text-main)]" />
|
|
273
|
+
</div>
|
|
274
|
+
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
|
|
275
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-2 leading-relaxed">
|
|
276
|
+
We understand that giving access to your private repositories can be a bit scary. So here's the deal: We install the KYD GitHub App in your account. The app has
|
|
277
|
+
<span className="mx-1"><Link href="https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-contents" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>Contents <ExternalLink className="size-3 inline-block" /></Link></span>
|
|
278
|
+
read access - only to the repositories you select. Then, once you request a badge assessment, we read the repositories and analyze the code, then its deleted, forever. Your code is not accessible to anyone, not even us.
|
|
279
|
+
</p>
|
|
280
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-3 leading-relaxed">
|
|
281
|
+
For other details, see our{' '}
|
|
282
|
+
<Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
|
|
283
|
+
Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
|
|
284
|
+
</Link>.
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
409
287
|
</div>
|
|
410
288
|
</motion.div>
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
289
|
+
) : selectedProvider && selectedProvider.id !== 'githubapp' ? (
|
|
290
|
+
<motion.div
|
|
291
|
+
key="connect-card"
|
|
292
|
+
initial="initial" animate="animate" exit="exit" variants={fadeOnly}
|
|
293
|
+
className="rounded-xl border max-w-xl w-full"
|
|
294
|
+
style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
|
|
295
|
+
>
|
|
296
|
+
<div className="sm:p-6 p-4">
|
|
297
|
+
<button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
|
|
298
|
+
<ArrowLeft className="w-4 h-4" />
|
|
299
|
+
Back
|
|
300
|
+
</button>
|
|
301
|
+
<div className="text-center">
|
|
302
|
+
<div className="flex justify-center mb-4">
|
|
303
|
+
<ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
|
|
304
|
+
</div>
|
|
305
|
+
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
|
|
306
|
+
{selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
|
|
307
|
+
? `Use Public ${selectedProvider.name} Profile`
|
|
308
|
+
: `Connect ${selectedProvider.name}`}
|
|
309
|
+
</h3>
|
|
310
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
|
|
311
|
+
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
|
|
312
|
+
? (selectedProvider.placeholder || 'Enter your public profile URL.')
|
|
313
|
+
: `Authorize with ${selectedProvider.name} to connect your account.`}
|
|
314
|
+
</p>
|
|
430
315
|
</div>
|
|
431
|
-
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>Manage GitHub Connections</h3>
|
|
432
|
-
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md">
|
|
433
|
-
Connect or disconnect your GitHub OAuth account and optional GitHub App for private repositories.
|
|
434
|
-
</p>
|
|
435
|
-
</div>
|
|
436
316
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
<
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
)
|
|
317
|
+
{(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
|
|
318
|
+
<motion.form
|
|
319
|
+
onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
|
|
320
|
+
className="mt-6 space-y-4"
|
|
321
|
+
initial="initial" animate="animate" exit="exit" variants={cardVariants}
|
|
322
|
+
>
|
|
323
|
+
{selectedProvider.id === 'linkedin' && (
|
|
324
|
+
<>
|
|
325
|
+
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
326
|
+
<Link
|
|
327
|
+
href="https://www.linkedin.com/public-profile/settings"
|
|
328
|
+
target="_blank"
|
|
329
|
+
rel="noopener noreferrer"
|
|
330
|
+
className="underline"
|
|
331
|
+
style={{ color: 'var(--icon-accent)' }}
|
|
332
|
+
>
|
|
333
|
+
LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
|
|
334
|
+
</Link>
|
|
335
|
+
. This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
|
|
336
|
+
</p>
|
|
337
|
+
<p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
|
|
338
|
+
LinkedIn data is not used to contribute to your score.
|
|
339
|
+
</p>
|
|
340
|
+
</>
|
|
341
|
+
|
|
342
|
+
)}
|
|
343
|
+
<div className="relative">
|
|
344
|
+
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
|
|
345
|
+
<Input
|
|
346
|
+
type="url"
|
|
347
|
+
value={linkUrl}
|
|
348
|
+
onChange={(e) => setLinkUrl(e.target.value)}
|
|
349
|
+
placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
|
|
350
|
+
required
|
|
351
|
+
className="w-full border bg-transparent p-2 pl-9"
|
|
352
|
+
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
353
|
+
onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
|
|
354
|
+
onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
|
|
355
|
+
/>
|
|
465
356
|
</div>
|
|
357
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
358
|
+
<Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
|
|
359
|
+
{isSubmitting ? (
|
|
360
|
+
<div className="flex items-center justify-center">
|
|
361
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
|
362
|
+
Connecting...
|
|
363
|
+
</div>
|
|
364
|
+
) : (
|
|
365
|
+
'Connect'
|
|
366
|
+
)}
|
|
367
|
+
</Button>
|
|
368
|
+
</motion.div>
|
|
369
|
+
</motion.form>
|
|
370
|
+
) : (
|
|
371
|
+
<div className="mt-6">
|
|
372
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
373
|
+
<Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
|
|
374
|
+
<ExternalLink className="w-4 h-4 mr-2" />
|
|
375
|
+
Connect with {selectedProvider.name}
|
|
376
|
+
</Button>
|
|
377
|
+
</motion.div>
|
|
466
378
|
</div>
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
</motion.div>
|
|
382
|
+
) : selectedProvider && selectedProvider.id === 'githubapp' ? (
|
|
383
|
+
<div
|
|
384
|
+
key="github-card"
|
|
385
|
+
className="rounded-xl border max-w-xl w-full"
|
|
386
|
+
style={{
|
|
387
|
+
backgroundColor: 'var(--content-card-background)',
|
|
388
|
+
borderColor: 'var(--icon-button-secondary)',
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
<motion.div className="p-6 flex flex-col items-center" initial="initial" animate="animate" exit="exit" variants={fadeOnly}>
|
|
392
|
+
<div className="w-full flex items-center gap-3 mb-2 justify-center">
|
|
393
|
+
<ProviderIcon name="github" className="w-8 h-8 inline-block" />
|
|
394
|
+
<span className="sm:text-xl text-base font-semibold text-[var(--text-main)]">Connect Private GitHub Repositories</span>
|
|
395
|
+
</div>
|
|
396
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed mt-1 mb-6 text-center max-w-md">
|
|
397
|
+
You've successfully linked your GitHub account!
|
|
398
|
+
<br />
|
|
399
|
+
To complete your profile, you can optionally allow access to your <b>private repositories</b>. This is useful if you'd like to highlight private work or share additional contributions for verification.
|
|
400
|
+
<br /><br />
|
|
401
|
+
<span className="text-[var(--text-main)] font-medium">
|
|
402
|
+
Would you like to connect your private repositories?
|
|
403
|
+
</span>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
onClick={() => setShowDataHandling(true)}
|
|
407
|
+
className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
|
|
408
|
+
>
|
|
409
|
+
How KYD Handles Your Data
|
|
410
|
+
</button>
|
|
411
|
+
</p>
|
|
412
|
+
<div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
|
|
413
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
473
414
|
<Button
|
|
474
|
-
className="
|
|
475
|
-
|
|
476
|
-
|
|
415
|
+
className="w-full sm:w-auto text-[var(--text-main)] transition-colors border border-[var(--icon-button-secondary)]"
|
|
416
|
+
variant="destructive"
|
|
417
|
+
onClick={() => {
|
|
418
|
+
handleConnectBack();
|
|
419
|
+
}}
|
|
477
420
|
>
|
|
478
|
-
|
|
421
|
+
No, don't connect
|
|
479
422
|
</Button>
|
|
480
|
-
</div>
|
|
481
|
-
<div
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
variant="destructive"
|
|
488
|
-
>
|
|
489
|
-
<Unlink className="size-3 sm:size-4" />
|
|
490
|
-
<span>Uninstall</span>
|
|
491
|
-
</Button>
|
|
492
|
-
) : (
|
|
493
|
-
<Button
|
|
494
|
-
onClick={onGithubAppInstall}
|
|
495
|
-
className="bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)]"
|
|
496
|
-
>
|
|
423
|
+
</motion.div>
|
|
424
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
425
|
+
<Button
|
|
426
|
+
className="w-full sm:w-auto bg-[var(--icon-accent)] text-white transition-colors font-semibold"
|
|
427
|
+
onClick={onGithubAppInstall}
|
|
428
|
+
>
|
|
429
|
+
<span className="flex items-center justify-center">
|
|
497
430
|
<ExternalLink className="w-4 h-4 mr-2" />
|
|
498
|
-
|
|
499
|
-
</
|
|
500
|
-
|
|
501
|
-
</div>
|
|
431
|
+
Yes, connect my private repos
|
|
432
|
+
</span>
|
|
433
|
+
</Button>
|
|
434
|
+
</motion.div>
|
|
502
435
|
</div>
|
|
503
|
-
</div>
|
|
436
|
+
</motion.div>
|
|
504
437
|
</div>
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
<
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
<
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
return (
|
|
558
|
-
<div key={providerId} className="group flex items-center justify-between p-2 rounded-lg transition-colors hover:bg-[var(--icon-button-secondary)]/10 transform transition-transform hover:-translate-y-px">
|
|
559
|
-
<div className="flex items-center gap-3">
|
|
560
|
-
|
|
561
|
-
{isConnected && connectedUrl ? (
|
|
562
|
-
<Link href={connectedUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:underline">
|
|
563
|
-
<ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
|
|
564
|
-
<span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
|
|
565
|
-
{isRequired && !isConnected ? (requiredFlag()) : null}
|
|
566
|
-
</Link>
|
|
567
|
-
) : (
|
|
438
|
+
) : (
|
|
439
|
+
<Card className="border-[var(--icon-button-secondary)] pt-2" style={{ backgroundColor: 'var(--content-card-background)'}}>
|
|
440
|
+
<AnimatePresence mode="wait">
|
|
441
|
+
<motion.div key="platform-list-shared" variants={cardVariants} initial="initial" animate="animate" exit="exit" transition={{ duration: 0.3 }}>
|
|
442
|
+
<CardHeader className="pb-4">
|
|
443
|
+
{handleBackButton && (
|
|
444
|
+
<button onClick={() => handleBackButton()} className="flex items-center gap-2 text-sm mb-4 text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors">
|
|
445
|
+
<ArrowLeft className="w-4 h-4" /> Back
|
|
446
|
+
</button>
|
|
447
|
+
)}
|
|
448
|
+
<CardTitle className="sm:text-xl text-base font-semibold text-[var(--text-main)] mb-2">{headerTitle}</CardTitle>
|
|
449
|
+
<p className="sm:text-sm text-xs text-[var(--text-secondary)] leading-relaxed">{headerDescription}</p>
|
|
450
|
+
</CardHeader>
|
|
451
|
+
<CardContent className="space-y-2">
|
|
452
|
+
{(() => {
|
|
453
|
+
const oauthList = list.filter(p => p.connectionType === 'oauth');
|
|
454
|
+
const urlList = list.filter(p => (p.connectionType || 'url') === 'url' || p.connectionType === 'link');
|
|
455
|
+
|
|
456
|
+
const Row = (provider: typeof list[number]) => {
|
|
457
|
+
const providerId = provider.id;
|
|
458
|
+
const isRequired = requiredProviders?.includes(providerId);
|
|
459
|
+
const isConnected = connectedIds.has(providerId.toLowerCase());
|
|
460
|
+
const needsReconnect = reconnectIds.has(providerId.toLowerCase());
|
|
461
|
+
const isOauth = provider.connectionType === 'oauth'
|
|
462
|
+
const connectedUrl = connected.find(c => c.name.toLowerCase() === providerId.toLowerCase())?.url;
|
|
463
|
+
const betaFlag = () => (
|
|
464
|
+
<span
|
|
465
|
+
className="ml-2 inline-block rounded-full px-2 py-0.5 text-xs font-semibold bg-blue-100 text-blue-700 border border-blue-200"
|
|
466
|
+
style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
|
|
467
|
+
>
|
|
468
|
+
Beta
|
|
469
|
+
</span>
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const requiredFlag = () => (
|
|
473
|
+
<TooltipProvider>
|
|
474
|
+
<Tooltip>
|
|
475
|
+
<TooltipTrigger>
|
|
476
|
+
<div className="flex items-center justify-center size-4 sm:size-5 rounded-full bg-[var(--icon-accent)] cursor-help">
|
|
477
|
+
<span className="text-white text-xs font-bold -translate-y-px select-none">!</span>
|
|
478
|
+
</div>
|
|
479
|
+
</TooltipTrigger>
|
|
480
|
+
<TooltipContent>
|
|
481
|
+
<p>Required by <span className="font-semibold">{companyName || 'your recruiter'}</span></p>
|
|
482
|
+
</TooltipContent>
|
|
483
|
+
</Tooltip>
|
|
484
|
+
</TooltipProvider>
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<div key={providerId} className="group flex items-center justify-between p-2 rounded-lg transition-colors hover:bg-[var(--icon-button-secondary)]/10 transform transition-transform hover:-translate-y-px">
|
|
568
489
|
<div className="flex items-center gap-3">
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
490
|
+
|
|
491
|
+
{isConnected && connectedUrl ? (
|
|
492
|
+
<Link href={connectedUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:underline">
|
|
493
|
+
<ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
|
|
494
|
+
<span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
|
|
495
|
+
{isRequired && !isConnected ? (requiredFlag()) : null}
|
|
496
|
+
</Link>
|
|
497
|
+
) : (
|
|
498
|
+
<div className="flex items-center gap-3">
|
|
499
|
+
<ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
|
|
500
|
+
<span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
|
|
501
|
+
{isRequired && !isConnected ? (requiredFlag()) : null}
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
{provider.beta ? betaFlag() : null}
|
|
572
505
|
</div>
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
506
|
+
|
|
507
|
+
{providerId.toLowerCase() === 'github' ? (
|
|
508
|
+
<div className="flex items-center gap-2">
|
|
509
|
+
{/* Public (OAuth) */}
|
|
510
|
+
{isGithubConnected ? (
|
|
511
|
+
<div className="relative flex items-center">
|
|
512
|
+
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
513
|
+
<CheckCircle className="size-3 sm:size-4" />
|
|
514
|
+
<span className="text-sm font-medium">Connected</span>
|
|
515
|
+
</div>
|
|
516
|
+
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
517
|
+
<button
|
|
518
|
+
onClick={() => onDisconnect('github')}
|
|
519
|
+
onMouseEnter={() => setPreview('github', 'disconnect')}
|
|
520
|
+
onMouseLeave={clearPreview}
|
|
521
|
+
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
522
|
+
disabled={isDisconnecting === 'github'}
|
|
523
|
+
>
|
|
524
|
+
{isDisconnecting === 'github' ? <Spinner /> : <Unlink className="size-3 sm:size-4" />}
|
|
525
|
+
<span>Disconnect</span>
|
|
526
|
+
</button>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
) : (
|
|
530
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
531
|
+
<Button
|
|
532
|
+
onClick={() => setSelectedProviderIdAndCallback('github')}
|
|
533
|
+
onMouseEnter={() => setPreview('github', 'connect')}
|
|
534
|
+
onMouseLeave={clearPreview}
|
|
535
|
+
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
536
|
+
>
|
|
537
|
+
<Eye className="size-3 sm:size-4" />
|
|
538
|
+
<span className="sm:text-base text-sm">Connect</span>
|
|
539
|
+
</Button>
|
|
540
|
+
</motion.div>
|
|
541
|
+
)}
|
|
542
|
+
|
|
543
|
+
{/* Private (GitHub App) */}
|
|
544
|
+
{isGithubAppInstalled ? (
|
|
545
|
+
<div className="relative flex items-center">
|
|
546
|
+
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
547
|
+
<CheckCircle className="size-3 sm:size-4" />
|
|
548
|
+
<span className="text-sm font-medium">Connected</span>
|
|
549
|
+
</div>
|
|
550
|
+
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
551
|
+
<button
|
|
552
|
+
onClick={() => { window.location.href = 'https://github.com/settings/installations'; }}
|
|
553
|
+
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
554
|
+
>
|
|
555
|
+
<Unlink className="size-3 sm:size-4" />
|
|
556
|
+
<span>Uninstall</span>
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
) : (
|
|
561
|
+
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
|
562
|
+
<Button
|
|
563
|
+
onClick={() => setSelectedProviderIdAndCallback('githubapp')}
|
|
564
|
+
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
565
|
+
>
|
|
566
|
+
<Lock className="size-3 sm:size-4" />
|
|
567
|
+
<span className="sm:text-base text-sm">Connect</span>
|
|
568
|
+
</Button>
|
|
569
|
+
</motion.div>
|
|
570
|
+
)}
|
|
571
|
+
</div>
|
|
572
|
+
) : isConnected && isDisconnecting !== providerId ? (
|
|
573
|
+
<div className="relative flex items-center">
|
|
603
574
|
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
604
575
|
<CheckCircle className="size-3 sm:size-4" />
|
|
605
576
|
<span className="text-sm font-medium">Connected</span>
|
|
606
577
|
</div>
|
|
607
578
|
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
608
|
-
<
|
|
579
|
+
<button
|
|
580
|
+
onClick={() => onDisconnect(providerId)}
|
|
581
|
+
onMouseEnter={() => setPreview(providerId, 'disconnect')}
|
|
582
|
+
onMouseLeave={clearPreview}
|
|
583
|
+
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
584
|
+
>
|
|
609
585
|
<Unlink className="size-3 sm:size-4" />
|
|
610
586
|
<span>Disconnect</span>
|
|
611
|
-
</
|
|
587
|
+
</button>
|
|
612
588
|
</div>
|
|
613
589
|
</div>
|
|
614
|
-
)
|
|
615
|
-
|
|
616
|
-
|
|
590
|
+
) : isDisconnecting === providerId ? (
|
|
591
|
+
<div className="relative flex items-center">
|
|
592
|
+
<Spinner />
|
|
593
|
+
</div>
|
|
594
|
+
) : (
|
|
595
|
+
<div className="flex items-center gap-2">
|
|
596
|
+
<>
|
|
597
|
+
<Button
|
|
598
|
+
onClick={() => setSelectedProviderIdAndCallback(providerId)}
|
|
599
|
+
onMouseEnter={() => setPreview(providerId, 'connect')}
|
|
600
|
+
onMouseLeave={clearPreview}
|
|
601
|
+
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
602
|
+
>
|
|
603
|
+
{isOauth ? (
|
|
604
|
+
<Link2 className="size-3 sm:size-4" />
|
|
605
|
+
) : (
|
|
606
|
+
<LinkIcon className="size-3 sm:size-4" />
|
|
607
|
+
)}
|
|
608
|
+
<span className="sm:text-base text-sm">Connect</span>
|
|
609
|
+
</Button>
|
|
610
|
+
{needsReconnect && !isRequired && (
|
|
611
|
+
<Button
|
|
612
|
+
onClick={() => onDisconnect(providerId)}
|
|
613
|
+
onMouseEnter={() => setPreview(providerId, 'disconnect')}
|
|
614
|
+
onMouseLeave={clearPreview}
|
|
615
|
+
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
|
|
616
|
+
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
617
|
+
variant="destructive"
|
|
618
|
+
>
|
|
619
|
+
<Unlink className="size-3 sm:size-4" />
|
|
620
|
+
<span>Remove</span>
|
|
621
|
+
</Button>
|
|
622
|
+
)}
|
|
623
|
+
</>
|
|
624
|
+
</div>
|
|
617
625
|
)}
|
|
618
626
|
</div>
|
|
619
|
-
)
|
|
620
|
-
|
|
621
|
-
<div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
|
|
622
|
-
<CheckCircle className="size-3 sm:size-4" />
|
|
623
|
-
<span className="text-sm font-medium">Connected</span>
|
|
624
|
-
</div>
|
|
625
|
-
<div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
|
|
626
|
-
<button
|
|
627
|
-
onClick={() => onDisconnect(providerId)}
|
|
628
|
-
className="inline-flex items-center gap-1.5 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
|
|
629
|
-
>
|
|
630
|
-
<Unlink className="size-3 sm:size-4" />
|
|
631
|
-
<span>Disconnect</span>
|
|
632
|
-
</button>
|
|
633
|
-
</div>
|
|
634
|
-
</div>
|
|
635
|
-
) : isDisconnecting === providerId ? (
|
|
636
|
-
<div className="relative flex items-center">
|
|
637
|
-
<Spinner />
|
|
638
|
-
</div>
|
|
639
|
-
) : (
|
|
640
|
-
<div className="flex items-center gap-2">
|
|
641
|
-
<>
|
|
642
|
-
<Button
|
|
643
|
-
onClick={() => setSelectedProviderIdAndCallback(providerId)}
|
|
644
|
-
className="bg-[var(--icon-button-secondary)] text-[var(--text-main)] hover:bg-[var(--icon-accent)] hover:text-white border-0 sm:px-4 px-3 py-1 sm:py-2 rounded-lg flex items-center gap-2"
|
|
645
|
-
>
|
|
646
|
-
{isOauth ? (
|
|
647
|
-
<Link2 className="size-3 sm:size-4" />
|
|
648
|
-
) : (
|
|
649
|
-
<LinkIcon className="size-3 sm:size-4" />
|
|
650
|
-
)}
|
|
651
|
-
<span className="sm:text-base text-sm">Connect</span>
|
|
652
|
-
</Button>
|
|
653
|
-
{needsReconnect && !isRequired && (
|
|
654
|
-
<Button
|
|
655
|
-
onClick={() => onDisconnect(providerId)}
|
|
656
|
-
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
|
|
657
|
-
style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
|
|
658
|
-
variant="destructive"
|
|
659
|
-
>
|
|
660
|
-
<Unlink className="size-3 sm:size-4" />
|
|
661
|
-
<span>Remove</span>
|
|
662
|
-
</Button>
|
|
663
|
-
)}
|
|
664
|
-
</>
|
|
665
|
-
</div>
|
|
666
|
-
)}
|
|
667
|
-
</div>
|
|
668
|
-
);
|
|
669
|
-
};
|
|
627
|
+
);
|
|
628
|
+
};
|
|
670
629
|
|
|
671
|
-
|
|
672
|
-
<>
|
|
673
|
-
{oauthList.length ? (
|
|
630
|
+
return (
|
|
674
631
|
<>
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
632
|
+
{oauthList.length ? (
|
|
633
|
+
<>
|
|
634
|
+
<div className="flex items-center my-2">
|
|
635
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
636
|
+
<span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>OAuth</span>
|
|
637
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
638
|
+
</div>
|
|
639
|
+
{oauthList.map(p => Row(p))}
|
|
640
|
+
</>
|
|
641
|
+
) : null}
|
|
683
642
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
643
|
+
{urlList.length ? (
|
|
644
|
+
<>
|
|
645
|
+
<div className="flex items-center my-2">
|
|
646
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
647
|
+
<span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>Public Urls</span>
|
|
648
|
+
<div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
|
|
649
|
+
</div>
|
|
650
|
+
{urlList.map(p => Row(p))}
|
|
651
|
+
</>
|
|
652
|
+
) : null}
|
|
692
653
|
</>
|
|
693
|
-
)
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
</
|
|
654
|
+
);
|
|
655
|
+
})()}
|
|
656
|
+
</CardContent>
|
|
657
|
+
</motion.div>
|
|
658
|
+
</AnimatePresence>
|
|
659
|
+
</Card>
|
|
660
|
+
)}
|
|
661
|
+
{shouldShowContinueButton && (
|
|
662
|
+
<motion.div className="mt-6 w-full" initial="hidden" animate="visible" variants={fadeIn}>
|
|
663
|
+
<motion.div whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.99 }}>
|
|
664
|
+
<Button
|
|
665
|
+
onClick={onContinue}
|
|
666
|
+
disabled={shouldDisableContinueButton}
|
|
667
|
+
className="w-full text-white font-medium py-2.5 bg-[var(--icon-accent)] disabled:opacity-50"
|
|
668
|
+
>
|
|
669
|
+
Continue
|
|
670
|
+
</Button>
|
|
671
|
+
</motion.div>
|
|
698
672
|
</motion.div>
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
{/* Desktop: progress card to the right, sticky while scrolling */}
|
|
678
|
+
<div className="hidden lg:block lg:sticky lg:top-6">
|
|
679
|
+
<ConnectProgress
|
|
680
|
+
layout="inline"
|
|
681
|
+
providers={providers}
|
|
682
|
+
connectedIds={connectedIds}
|
|
683
|
+
previewProviderId={previewProviderId}
|
|
684
|
+
previewAction={previewAction}
|
|
685
|
+
selectedProviderId={selectedProvider?.id || null}
|
|
686
|
+
/>
|
|
687
|
+
</div>
|
|
688
|
+
</div>
|
|
704
689
|
);
|
|
705
690
|
}
|
|
706
691
|
|
package/src/connect/types.ts
CHANGED
|
@@ -8,10 +8,15 @@ export type ConnectProgressProps = {
|
|
|
8
8
|
connectedIds: Set<string>
|
|
9
9
|
className?: string
|
|
10
10
|
layout?: 'fixed-desktop' | 'inline'
|
|
11
|
+
// Preview what the score would be if a connect/disconnect action occurred
|
|
12
|
+
previewProviderId?: string | null
|
|
13
|
+
previewAction?: 'connect' | 'disconnect' | null
|
|
14
|
+
// When a provider is selected (in the connect screen), show a pulsing gain segment
|
|
15
|
+
selectedProviderId?: string | null
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export function ConnectProgress(props: ConnectProgressProps) {
|
|
14
|
-
const { providers, connectedIds, className, layout = 'fixed-desktop' } = props
|
|
19
|
+
const { providers, connectedIds, className, layout = 'fixed-desktop', previewProviderId, previewAction, selectedProviderId } = props
|
|
15
20
|
|
|
16
21
|
const hasGithub = connectedIds.has('github')
|
|
17
22
|
const hasLinkedIn = connectedIds.has('linkedin')
|
|
@@ -21,12 +26,33 @@ export function ConnectProgress(props: ConnectProgressProps) {
|
|
|
21
26
|
return count
|
|
22
27
|
}, [connectedIds])
|
|
23
28
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
29
|
+
const computePercent = (ids: Set<string>) => {
|
|
30
|
+
const gh = ids.has('github')
|
|
31
|
+
const li = ids.has('linkedin')
|
|
32
|
+
let others = 0
|
|
33
|
+
ids.forEach(id => {
|
|
34
|
+
const lid = id.toLowerCase()
|
|
35
|
+
if (lid !== 'github' && lid !== 'linkedin') others += 1
|
|
36
|
+
})
|
|
37
|
+
const githubWeight = gh ? 65 : 0
|
|
38
|
+
const linkedinWeight = li ? 15 : 0
|
|
39
|
+
const othersWeight = Math.min(others, 2) * 10
|
|
28
40
|
return Math.max(0, Math.min(100, githubWeight + linkedinWeight + othersWeight))
|
|
29
|
-
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const progressPercent = useMemo(() => computePercent(connectedIds), [connectedIds])
|
|
44
|
+
|
|
45
|
+
const previewPercent = useMemo(() => {
|
|
46
|
+
const pid = (previewProviderId || '').toLowerCase()
|
|
47
|
+
if (!pid || !previewAction) return null as number | null
|
|
48
|
+
if (pid === 'githubapp') return null
|
|
49
|
+
const next = new Set<string>(Array.from(connectedIds))
|
|
50
|
+
const has = next.has(pid)
|
|
51
|
+
if (previewAction === 'connect' && !has) next.add(pid)
|
|
52
|
+
if (previewAction === 'disconnect' && has) next.delete(pid)
|
|
53
|
+
const val = computePercent(next)
|
|
54
|
+
return val === progressPercent ? null : val
|
|
55
|
+
}, [previewProviderId, previewAction, connectedIds, progressPercent])
|
|
30
56
|
|
|
31
57
|
const progressCopy = useMemo(() => {
|
|
32
58
|
const score = progressPercent
|
|
@@ -72,18 +98,45 @@ export function ConnectProgress(props: ConnectProgressProps) {
|
|
|
72
98
|
}
|
|
73
99
|
}, [progressPercent, hasGithub])
|
|
74
100
|
|
|
101
|
+
const selectedPulse = useMemo(() => {
|
|
102
|
+
const sid = (selectedProviderId || '').toLowerCase()
|
|
103
|
+
if (!sid || sid === 'githubapp') return { from: undefined as number | undefined, to: undefined as number | undefined }
|
|
104
|
+
// Do not show if already connected
|
|
105
|
+
if (connectedIds.has(sid)) return { from: undefined as number | undefined, to: undefined as number | undefined }
|
|
106
|
+
// Potential gain if they proceed with this provider
|
|
107
|
+
let gain = 0
|
|
108
|
+
if (sid === 'github') gain = 65
|
|
109
|
+
else if (sid === 'linkedin') gain = 15
|
|
110
|
+
else {
|
|
111
|
+
// Others contribute up to 2*10 capped
|
|
112
|
+
const othersAlready = otherConnectedCount
|
|
113
|
+
if (othersAlready < 2) gain = 10
|
|
114
|
+
}
|
|
115
|
+
const to = Math.max(0, Math.min(100, progressPercent + gain))
|
|
116
|
+
return { from: progressPercent, to }
|
|
117
|
+
}, [selectedProviderId, connectedIds, progressPercent, otherConnectedCount])
|
|
118
|
+
|
|
119
|
+
const valueToShow = previewPercent ?? progressPercent
|
|
120
|
+
|
|
75
121
|
const content = (
|
|
76
122
|
<Card className={`border-[var(--icon-button-secondary)] ${className || ''}`} style={{ backgroundColor: 'var(--content-card-background)'}}>
|
|
77
123
|
<CardContent className="py-3 px-3">
|
|
78
124
|
<div className="flex items-center gap-3">
|
|
79
|
-
<ProgressCircle
|
|
125
|
+
<ProgressCircle
|
|
126
|
+
value={valueToShow}
|
|
127
|
+
size={64}
|
|
128
|
+
thickness={6}
|
|
129
|
+
highlightFrom={selectedPulse.from}
|
|
130
|
+
highlightTo={selectedPulse.to}
|
|
131
|
+
highlightPulse={Boolean(selectedPulse.to)}
|
|
132
|
+
/>
|
|
80
133
|
<div className="min-w-0 max-w-xs">
|
|
81
134
|
<div className="text-base font-semibold truncate" style={{ color: 'var(--text-main)'}}>{progressCopy.headline}</div>
|
|
82
135
|
<div className="text-sm mt-0.5" style={{ color: 'var(--text-secondary)'}}>
|
|
83
136
|
{progressCopy.body}
|
|
84
137
|
</div>
|
|
85
138
|
<div className="text-sm mt-1" style={{ color: 'var(--text-secondary)'}}>
|
|
86
|
-
Weighted score • {
|
|
139
|
+
Weighted score • {valueToShow}%
|
|
87
140
|
</div>
|
|
88
141
|
</div>
|
|
89
142
|
</div>
|
|
@@ -8,10 +8,14 @@ export type ProgressCircleProps = {
|
|
|
8
8
|
className?: string
|
|
9
9
|
showLabel?: boolean
|
|
10
10
|
label?: string
|
|
11
|
+
// Optional highlight segment (e.g., to preview gain from an action)
|
|
12
|
+
highlightFrom?: number
|
|
13
|
+
highlightTo?: number
|
|
14
|
+
highlightPulse?: boolean
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export function ProgressCircle(props: ProgressCircleProps) {
|
|
14
|
-
const { value, size = 72, thickness = 6, className, showLabel = true, label } = props
|
|
18
|
+
const { value, size = 72, thickness = 6, className, showLabel = true, label, highlightFrom, highlightTo, highlightPulse } = props
|
|
15
19
|
|
|
16
20
|
const rawId = React.useId()
|
|
17
21
|
const gradientId = React.useMemo(() => `kyd-pc-${String(rawId).replace(/:/g, '')}` , [rawId])
|
|
@@ -21,6 +25,12 @@ export function ProgressCircle(props: ProgressCircleProps) {
|
|
|
21
25
|
const circumference = 2 * Math.PI * radius
|
|
22
26
|
const dashOffset = circumference * (1 - clamped / 100)
|
|
23
27
|
|
|
28
|
+
const hasHighlight = Number.isFinite(highlightFrom as number) && Number.isFinite(highlightTo as number) && (typeof highlightFrom === 'number') && (typeof highlightTo === 'number') && highlightTo! > (highlightFrom as number)
|
|
29
|
+
const fromClamped = hasHighlight ? Math.max(0, Math.min(100, highlightFrom as number)) : 0
|
|
30
|
+
const toClamped = hasHighlight ? Math.max(0, Math.min(100, highlightTo as number)) : 0
|
|
31
|
+
const highlightLength = hasHighlight ? circumference * ((toClamped - fromClamped) / 100) : 0
|
|
32
|
+
const highlightOffset = hasHighlight ? circumference * (1 - toClamped / 100) : 0
|
|
33
|
+
|
|
24
34
|
return (
|
|
25
35
|
<div className={`relative inline-flex items-center justify-center ${className || ''}`} style={{ width: size, height: size }}>
|
|
26
36
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
|
|
@@ -52,6 +62,22 @@ export function ProgressCircle(props: ProgressCircleProps) {
|
|
|
52
62
|
style={{ transition: 'stroke-dashoffset 800ms ease' }}
|
|
53
63
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
54
64
|
/>
|
|
65
|
+
{hasHighlight ? (
|
|
66
|
+
<circle
|
|
67
|
+
cx={size / 2}
|
|
68
|
+
cy={size / 2}
|
|
69
|
+
r={radius}
|
|
70
|
+
fill="none"
|
|
71
|
+
stroke="var(--icon-accent)"
|
|
72
|
+
strokeOpacity={0.6}
|
|
73
|
+
strokeWidth={thickness}
|
|
74
|
+
strokeLinecap="round"
|
|
75
|
+
strokeDasharray={`${highlightLength} ${circumference}`}
|
|
76
|
+
strokeDashoffset={highlightOffset}
|
|
77
|
+
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
78
|
+
className={highlightPulse ? 'animate-pulse' : ''}
|
|
79
|
+
/>
|
|
80
|
+
) : null}
|
|
55
81
|
{/* subtle pulsing halo */}
|
|
56
82
|
<circle
|
|
57
83
|
cx={size / 2}
|