kyd-shared-badge 0.3.69 → 0.3.71

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.69",
3
+ "version": "0.3.71",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -22,7 +22,9 @@
22
22
  "@chatscope/chat-ui-kit-react": "^2.1.1",
23
23
  "@chatscope/chat-ui-kit-styles": "^1.4.0",
24
24
  "ai": "5.0.47",
25
+ "framer-motion": "^12.23.24",
25
26
  "i18n-iso-countries": "^7.14.0",
27
+ "lucide-react": "^0.545.0",
26
28
  "next-auth": "^4.24.11",
27
29
  "react-hot-toast": "^2.6.0",
28
30
  "react-icons": "^5.5.0",
@@ -3,6 +3,10 @@ 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 } from 'lucide-react';
7
+ import { AnimatePresence, motion } from 'framer-motion';
8
+ import { Button, Input, Spinner } from '../ui';
9
+ import Link from 'next/link';
6
10
 
7
11
  function byPriority(a: string, b: string) {
8
12
  const pr = (id: string) => (id === 'github' ? 0 : id === 'linkedin' ? 1 : 2);
@@ -26,6 +30,8 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
26
30
  onDisconnected,
27
31
  onError,
28
32
  className,
33
+ oauthClientIds,
34
+ providerPickCallback,
29
35
  } = props;
30
36
 
31
37
  const router = useRouter();
@@ -87,6 +93,11 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
87
93
  }
88
94
  };
89
95
 
96
+ const setSelectedProviderIdAndCallback = (providerId: string | null) => {
97
+ setSelectedProviderId(providerId);
98
+ providerPickCallback(providerId);
99
+ };
100
+
90
101
  const onSubmitLink = async (providerId: string) => {
91
102
  const provider = providers.find(p => p.id.toLowerCase() === providerId.toLowerCase());
92
103
  if (!provider || !provider.endpoint) return;
@@ -104,7 +115,8 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
104
115
  });
105
116
  const data = await res.json().catch(() => ({}));
106
117
  if (!res.ok) throw new Error(data?.error || `Failed to sync ${provider.name} profile.`);
107
- setSelectedProviderId(null);
118
+ setSelectedProviderIdAndCallback(null);
119
+
108
120
  setLinkUrl('');
109
121
  if (onConnected) onConnected(providerId);
110
122
  } catch (e) {
@@ -122,97 +134,242 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
122
134
  // Lazy import builder to avoid circular deps
123
135
  // eslint-disable-next-line @typescript-eslint/no-var-requires
124
136
  const { buildOAuthUrl } = require('./oauth');
125
- const url = buildOAuthUrl({ providerId: id, redirectUri, state });
137
+ const url = buildOAuthUrl({ providerId: id, redirectUri, state, clientIds: oauthClientIds });
126
138
  if (url) router.push(url);
127
139
  };
128
140
 
141
+ const selectedProvider = useMemo(
142
+ () => (selectedProviderId ? providers.find(p => p.id.toLowerCase() === selectedProviderId.toLowerCase()) : null),
143
+ [selectedProviderId, providers]
144
+ );
145
+
146
+ const handleConnectBack = () => {
147
+ setSelectedProviderIdAndCallback(null);
148
+ setLinkUrl('');
149
+ };
150
+
151
+ const fadeIn = {
152
+ hidden: { opacity: 0, y: 6 },
153
+ visible: { opacity: 1, y: 0, transition: { duration: 0.15 } },
154
+ exit: { opacity: 0, y: 6, transition: { duration: 0.1 } },
155
+ } as const;
156
+
129
157
  return (
130
- <div className={className}>
131
- {list.map((provider) => {
132
- const providerId = provider.id;
133
- const isConnected = connectedIds.has(providerId.toLowerCase());
134
- const needsReconnect = reconnectIds.has(providerId.toLowerCase());
135
- const isUrl = (provider.connectionType || 'url') === 'url' || provider.connectionType === 'link';
136
- const isOauth = provider.connectionType === 'oauth';
137
- return (
138
- <div key={providerId} className="group flex items-center justify-between p-2 rounded-lg transition-colors hover:bg-[var(--icon-button-secondary)]/10">
139
- <div className="flex items-center gap-3">
140
- <ProviderIcon name={providerId} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
141
- <span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
142
- {provider.beta ? (
143
- <span
144
- 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"
145
- style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
158
+ <>
159
+ {selectedProvider ? (
160
+ <AnimatePresence>
161
+ <motion.div
162
+ key="connect-card"
163
+ className="rounded-xl border max-w-xl w-full"
164
+ style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}}
165
+ initial="hidden" animate="visible" exit="exit" variants={fadeIn}
166
+ >
167
+ <div className="sm:p-6 p-4">
168
+ <button onClick={handleConnectBack} className="flex items-center gap-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-main)] transition-colors mb-4">
169
+ <ArrowLeft className="w-4 h-4" />
170
+ Back
171
+ </button>
172
+ <div className="text-center">
173
+ <div className="flex justify-center mb-4">
174
+ <ProviderIcon name={selectedProvider.id} className={`w-10 h-10 ${selectedProvider.iconColor || 'text-gray-500'}`} />
175
+ </div>
176
+ <h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>
177
+ {selectedProvider.connectionType === 'url' || (selectedProvider.connectionType || 'url') === 'link'
178
+ ? `Use Public ${selectedProvider.name} Profile`
179
+ : `Connect ${selectedProvider.name}`}
180
+ </h3>
181
+ <p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto">
182
+ {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link')
183
+ ? (selectedProvider.placeholder || 'Enter your public profile URL.')
184
+ : `Authorize with ${selectedProvider.name} to connect your account.`}
185
+ </p>
186
+ </div>
187
+
188
+ {(selectedProvider.connectionType === 'url' || selectedProvider.connectionType === 'link') ? (
189
+ <motion.form
190
+ onSubmit={(e) => { e.preventDefault(); onSubmitLink(selectedProvider.id); }}
191
+ className="mt-6 space-y-4"
192
+ initial="hidden" animate="visible" variants={fadeIn}
146
193
  >
147
- Beta
148
- </span>
149
- ) : null}
194
+ {selectedProvider.id === 'linkedin' && (
195
+ <p className="sm:text-xs items-center text-[10px] text-[var(--text-secondary)] leading-relaxed max-w-xs mx-auto -mt-2">
196
+ <Link
197
+ href="https://www.linkedin.com/public-profile/settings"
198
+ target="_blank"
199
+ rel="noopener noreferrer"
200
+ className="underline"
201
+ style={{ color: 'var(--icon-accent)' }}
202
+ >
203
+ LinkedIn <ExternalLink className="size-3 inline-block ml-1 underline-0" />
204
+ </Link>
205
+ . This opens your public profile settings (you’ll see your shareable URL if you’re signed in).
206
+ </p>
207
+ )}
208
+ <div className="relative">
209
+ <LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" />
210
+ <Input
211
+ type="url"
212
+ value={linkUrl}
213
+ onChange={(e) => setLinkUrl(e.target.value)}
214
+ placeholder={selectedProvider.placeholder || 'https://example.com/your-profile'}
215
+ required
216
+ className="w-full border bg-transparent p-2 pl-9"
217
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
218
+ onPaste={selectedProvider.id === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
219
+ onBlur={selectedProvider.id === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
220
+ />
221
+ </div>
222
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
223
+ <Button type="submit" className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors" disabled={isSubmitting}>
224
+ {isSubmitting ? (
225
+ <div className="flex items-center justify-center">
226
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
227
+ Connecting...
228
+ </div>
229
+ ) : (
230
+ 'Connect'
231
+ )}
232
+ </Button>
233
+ </motion.div>
234
+ </motion.form>
235
+ ) : (
236
+ <div className="mt-6">
237
+ <motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
238
+ <Button onClick={() => onOAuth(selectedProvider.id)} className="w-full bg-[var(--icon-accent)] text-white hover:bg-[var(--icon-accent-hover)] transition-colors">
239
+ <ExternalLink className="w-4 h-4 mr-2" />
240
+ Connect with {selectedProvider.name}
241
+ </Button>
242
+ </motion.div>
243
+ </div>
244
+ )}
150
245
  </div>
246
+ </motion.div>
247
+ </AnimatePresence>
248
+ ) : (
249
+ <motion.div className="rounded-xl border max-w-lg" style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)'}} initial="hidden" animate="visible" variants={fadeIn}>
250
+ <div className="sm:p-6 p-4">
251
+ <h2 className="sm:text-xl text-base font-semibold" style={{ color: 'var(--text-main)'}}>Connect accounts</h2>
252
+ <p className="mt-2 sm:text-sm text-xs" style={{ color: 'var(--text-secondary)'}}>
253
+ Each connected account adds verified signals to your report. Connect more accounts to strengthen your score—then disconnect anytime.
254
+ </p>
255
+ </div>
256
+ <motion.div className="sm:px-6 px-4 sm:pb-6 pb-4 space-y-2" initial="hidden" animate="visible" variants={fadeIn}>
257
+ <div className="space-y-2">
258
+ {(() => {
259
+ const oauthList = list.filter(p => p.connectionType === 'oauth');
260
+ const urlList = list.filter(p => (p.connectionType || 'url') === 'url' || p.connectionType === 'link');
151
261
 
152
- {isConnected ? (
153
- <div className="relative flex items-center">
154
- <div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
155
- {/* Check icon via CSS/emoji to avoid extra deps */}
156
- <span className="w-4 h-4 inline-block">✔</span>
157
- <span className="text-sm font-medium">Connected</span>
158
- </div>
159
- <div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
160
- <button
161
- onClick={() => onDisconnect(providerId)}
162
- className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 hover:text-red-700"
163
- >
164
- <span className="w-3 h-3 inline-block">✕</span>
165
- <span>Disconnect</span>
166
- </button>
167
- </div>
168
- </div>
169
- ) : (
170
- <div className="flex items-center gap-2">
171
- {selectedProviderId === providerId && isUrl ? (
172
- <form onSubmit={(e) => { e.preventDefault(); onSubmitLink(providerId); }} className="flex items-center gap-2">
173
- <div className="relative">
174
- <input
175
- type="url"
176
- value={linkUrl}
177
- onChange={(e) => setLinkUrl(e.target.value)}
178
- placeholder={provider.placeholder || 'https://example.com/your-profile'}
179
- required
180
- className="w-72 border bg-transparent p-2 text-sm rounded"
181
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
182
- onPaste={providerId === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
183
- onBlur={providerId === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
184
- />
262
+ const Row = (provider: typeof list[number]) => {
263
+ const providerId = provider.id;
264
+ const isConnected = connectedIds.has(providerId.toLowerCase());
265
+ const needsReconnect = reconnectIds.has(providerId.toLowerCase());
266
+ const isOauth = provider.connectionType === 'oauth'
267
+ const connectedUrl = connected.find(c => c.id.toLowerCase() === providerId.toLowerCase())?.url;
268
+ const betaFlag = () => (
269
+ <span
270
+ 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"
271
+ style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
272
+ >
273
+ Beta
274
+ </span>
275
+ );
276
+
277
+ return (
278
+ <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">
279
+ <div className="flex items-center gap-3">
280
+
281
+ {isConnected && connectedUrl ? (
282
+ <Link href={connectedUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 hover:underline">
283
+ <ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
284
+ <span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
285
+ </Link>
286
+ ) : (
287
+ <div className="flex items-center gap-3">
288
+ <ProviderIcon name={provider.id} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
289
+ <span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
290
+ </div>
291
+ )}
292
+ {provider.beta ? betaFlag() : null}
293
+ </div>
294
+
295
+ {isConnected ? (
296
+ <div className="relative flex items-center">
297
+ <div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
298
+ <CheckCircle className="size-3 sm:size-4" />
299
+ <span className="text-sm font-medium">Connected</span>
300
+ </div>
301
+ <div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
302
+ <button
303
+ onClick={() => onDisconnect(providerId)}
304
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
305
+ >
306
+ <Unlink className="size-3 sm:size-4" />
307
+ <span>Disconnect</span>
308
+ </button>
309
+ </div>
310
+ </div>
311
+ ) : (
312
+ <div className="flex items-center gap-2">
313
+ <>
314
+ <Button
315
+ onClick={() => setSelectedProviderIdAndCallback(providerId)}
316
+ 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"
317
+ >
318
+ {isOauth ? (
319
+ <Link2 className="size-3 sm:size-4" />
320
+ ) : (
321
+ <LinkIcon className="size-3 sm:size-4" />
322
+ )}
323
+ <span className="sm:text-base text-sm">Connect</span>
324
+ </Button>
325
+ {needsReconnect && (
326
+ <Button
327
+ onClick={() => onDisconnect(providerId)}
328
+ className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
329
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
330
+ >
331
+ <Unlink className="size-3 sm:size-4" />
332
+ <span>Remove</span>
333
+ </Button>
334
+ )}
335
+ </>
336
+ </div>
337
+ )}
185
338
  </div>
186
- <button type="submit" disabled={isSubmitting} className="px-3 py-2 rounded bg-[var(--icon-accent)] text-white">
187
- {isSubmitting ? 'Connecting…' : 'Connect'}
188
- </button>
189
- </form>
190
- ) : (
339
+ );
340
+ };
341
+
342
+ return (
191
343
  <>
192
- <button
193
- onClick={() => (isOauth ? onOAuth(providerId) : setSelectedProviderId(providerId))}
194
- 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"
195
- >
196
- <span className="sm:text-base text-sm">Connect</span>
197
- </button>
198
- {needsReconnect && (
199
- <button
200
- onClick={() => onDisconnect(providerId)}
201
- className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
202
- style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
203
- >
204
- <span className="w-3 h-3 inline-block">✕</span>
205
- <span>Remove</span>
206
- </button>
207
- )}
344
+ {oauthList.length ? (
345
+ <>
346
+ <div className="flex items-center my-2">
347
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
348
+ <span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>OAuth</span>
349
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
350
+ </div>
351
+ {oauthList.map(p => Row(p))}
352
+ </>
353
+ ) : null}
354
+
355
+ {urlList.length ? (
356
+ <>
357
+ <div className="flex items-center my-2">
358
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
359
+ <span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>Public Urls</span>
360
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
361
+ </div>
362
+ {urlList.map(p => Row(p))}
363
+ </>
364
+ ) : null}
208
365
  </>
209
- )}
210
- </div>
211
- )}
212
- </div>
213
- );
214
- })}
215
- </div>
366
+ );
367
+ })()}
368
+ </div>
369
+ </motion.div>
370
+ </motion.div>
371
+ )}
372
+ </>
216
373
  );
217
374
  }
218
375
 
@@ -1,15 +1,16 @@
1
1
  type KnownProvider = 'github' | 'gitlab' | 'stackoverflow';
2
2
 
3
3
  const env = (key: string): string | undefined => {
4
- // Access process.env directly; Next.js will inline NEXT_PUBLIC_* at build time
5
- try { return (process.env as any)[key]; } catch { return undefined; }
4
+ // Use globalThis to avoid hard dependency on Node 'process' types/environ
5
+ try {
6
+ const g = globalThis as unknown as { process?: { env?: Record<string, string | undefined> } };
7
+ return g?.process?.env?.[key];
8
+ } catch {
9
+ return undefined;
10
+ }
6
11
  };
7
12
 
8
- const CLIENT_IDS: Record<KnownProvider, string | undefined> = {
9
- github: env('NEXT_PUBLIC_GITHUB_CLIENT_ID'),
10
- gitlab: env('NEXT_PUBLIC_GITLAB_CLIENT_ID'),
11
- stackoverflow: env('NEXT_PUBLIC_STACKOVERFLOW_CLIENT_ID'),
12
- };
13
+ export type OAuthClientIds = Partial<Record<KnownProvider, string>>;
13
14
 
14
15
  const DEFAULT_SCOPES: Record<KnownProvider, string> = {
15
16
  github: 'read:user,repo',
@@ -22,9 +23,13 @@ export function buildOAuthUrl(params: {
22
23
  redirectUri: string;
23
24
  state?: string;
24
25
  scopeOverride?: string;
26
+ clientIds?: OAuthClientIds;
25
27
  }): string {
26
28
  const id = (params.providerId || '').toLowerCase() as KnownProvider;
27
- const clientId = CLIENT_IDS[id as KnownProvider];
29
+ const clientId = (params.clientIds && params.clientIds[id]) || env(
30
+ id === 'github' ? 'NEXT_PUBLIC_GITHUB_CLIENT_ID' : id === 'gitlab' ? 'NEXT_PUBLIC_GITLAB_CLIENT_ID' : 'NEXT_PUBLIC_STACKOVERFLOW_CLIENT_ID'
31
+ );
32
+ // clientId should be inlined at build-time for NEXT_PUBLIC_*
28
33
  const scope = params.scopeOverride ?? DEFAULT_SCOPES[id as KnownProvider] ?? '';
29
34
  const redirect = encodeURIComponent(params.redirectUri);
30
35
  const state = params.state ? `&state=${encodeURIComponent(params.state)}` : '';
@@ -27,10 +27,12 @@ export interface ConnectAccountsProps {
27
27
  buildOAuthState?: () => Record<string, string | number | boolean> | string | undefined;
28
28
  stateFormat?: StateFormat;
29
29
  redirectUriOverrides?: RedirectUriOverrides;
30
+ oauthClientIds?: Partial<Record<'github' | 'gitlab' | 'stackoverflow', string>>;
30
31
  onConnected?: (providerId: string) => void;
31
32
  onDisconnected?: (providerId: string) => void;
32
33
  onError?: (message: string) => void;
33
34
  className?: string;
35
+ providerPickCallback: (providerId: string | null) => void;
34
36
  }
35
37
 
36
38
 
@@ -0,0 +1,57 @@
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16
+ outline:
17
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20
+ ghost: "hover:bg-accent hover:text-accent-foreground",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2",
25
+ sm: "h-8 rounded-md px-3 text-xs",
26
+ lg: "h-10 rounded-md px-8",
27
+ icon: "h-9 w-9",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "default",
33
+ },
34
+ }
35
+ )
36
+
37
+ export interface ButtonProps
38
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
39
+ VariantProps<typeof buttonVariants> {
40
+ asChild?: boolean
41
+ }
42
+
43
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
45
+ const Comp = asChild ? Slot : "button"
46
+ return (
47
+ <Comp
48
+ className={cn(buttonVariants({ variant, size, className }))}
49
+ ref={ref}
50
+ {...props}
51
+ />
52
+ )
53
+ }
54
+ )
55
+ Button.displayName = "Button"
56
+
57
+ export { Button, buttonVariants }
@@ -0,0 +1,76 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Card = React.forwardRef<
6
+ HTMLDivElement,
7
+ React.HTMLAttributes<HTMLDivElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div
10
+ ref={ref}
11
+ className={cn(
12
+ "rounded-lg border bg-card text-card-foreground shadow",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ ))
18
+ Card.displayName = "Card"
19
+
20
+ const CardHeader = React.forwardRef<
21
+ HTMLDivElement,
22
+ React.HTMLAttributes<HTMLDivElement>
23
+ >(({ className, ...props }, ref) => (
24
+ <div
25
+ ref={ref}
26
+ className={cn("flex flex-col sm:p-4 p-4", className)}
27
+ {...props}
28
+ />
29
+ ))
30
+ CardHeader.displayName = "CardHeader"
31
+
32
+ const CardTitle = React.forwardRef<
33
+ HTMLDivElement,
34
+ React.HTMLAttributes<HTMLDivElement>
35
+ >(({ className, ...props }, ref) => (
36
+ <div
37
+ ref={ref}
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ ))
42
+ CardTitle.displayName = "CardTitle"
43
+
44
+ const CardDescription = React.forwardRef<
45
+ HTMLDivElement,
46
+ React.HTMLAttributes<HTMLDivElement>
47
+ >(({ className, ...props }, ref) => (
48
+ <div
49
+ ref={ref}
50
+ className={cn("text-sm text-muted-foreground", className)}
51
+ {...props}
52
+ />
53
+ ))
54
+ CardDescription.displayName = "CardDescription"
55
+
56
+ const CardContent = React.forwardRef<
57
+ HTMLDivElement,
58
+ React.HTMLAttributes<HTMLDivElement>
59
+ >(({ className, ...props }, ref) => (
60
+ <div ref={ref} className={cn("sm:pt-2 sm:pb-4 sm:px-4 pb-3 px-4 pt-0", className)} {...props} />
61
+ ))
62
+ CardContent.displayName = "CardContent"
63
+
64
+ const CardFooter = React.forwardRef<
65
+ HTMLDivElement,
66
+ React.HTMLAttributes<HTMLDivElement>
67
+ >(({ className, ...props }, ref) => (
68
+ <div
69
+ ref={ref}
70
+ className={cn("flex items-center sm:p-6 p-2 pt-0", className)}
71
+ {...props}
72
+ />
73
+ ))
74
+ CardFooter.displayName = "CardFooter"
75
+
76
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,4 @@
1
+ export * from './button';
2
+ export * from './card';
3
+ export * from './input';
4
+ export * from './spinner';
@@ -0,0 +1,22 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
6
+ ({ className, type, ...props }, ref) => {
7
+ return (
8
+ <input
9
+ type={type}
10
+ className={cn(
11
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-xs",
12
+ className
13
+ )}
14
+ ref={ref}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+ )
20
+ Input.displayName = "Input"
21
+
22
+ export { Input }
@@ -0,0 +1,12 @@
1
+ 'use client'
2
+ import React from 'react'
3
+
4
+ export function Spinner() {
5
+ return (
6
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--icon-accent-hover)]"></div>
7
+ )
8
+ }
9
+
10
+ export default Spinner
11
+
12
+