kyd-shared-badge 0.3.68 → 0.3.70

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.68",
3
+ "version": "0.3.70",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -23,6 +23,7 @@
23
23
  "@chatscope/chat-ui-kit-styles": "^1.4.0",
24
24
  "ai": "5.0.47",
25
25
  "i18n-iso-countries": "^7.14.0",
26
+ "lucide-react": "^0.545.0",
26
27
  "next-auth": "^4.24.11",
27
28
  "react-hot-toast": "^2.6.0",
28
29
  "react-icons": "^5.5.0",
@@ -0,0 +1,262 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { useRouter } from 'next/navigation';
3
+ import { ProviderIcon } from '../utils/provider';
4
+ import { normalizeLinkedInInput } from './linkedin';
5
+ import type { ConnectAccountsProps } from './types';
6
+ import { CheckCircle, Link2, LinkIcon, Unlink } from 'lucide-react';
7
+ import { Button, Input, Spinner, Card, CardHeader, CardContent, CardFooter } from '../ui';
8
+
9
+ function byPriority(a: string, b: string) {
10
+ const pr = (id: string) => (id === 'github' ? 0 : id === 'linkedin' ? 1 : 2);
11
+ const da = pr(a.toLowerCase());
12
+ const db = pr(b.toLowerCase());
13
+ return da - db;
14
+ }
15
+
16
+ export function ConnectAccounts(props: ConnectAccountsProps) {
17
+ const {
18
+ providers,
19
+ connected,
20
+ idToken,
21
+ apiGatewayUrl,
22
+ companyId,
23
+ needsReconnectIds,
24
+ sort = true,
25
+ buildOAuthState,
26
+ redirectUriOverrides,
27
+ onConnected,
28
+ onDisconnected,
29
+ onError,
30
+ className,
31
+ } = props;
32
+
33
+ const router = useRouter();
34
+ const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
35
+ const [linkUrl, setLinkUrl] = useState('');
36
+ const [isSubmitting, setIsSubmitting] = useState(false);
37
+
38
+ const apiBase = apiGatewayUrl || (typeof process !== 'undefined' ? (process.env.NEXT_PUBLIC_API_GATEWAY_URL as string) : '');
39
+ const connectedIds = useMemo(() => new Set((connected || []).map(c => c.id.toLowerCase())), [connected]);
40
+ const reconnectIds = useMemo(() => new Set((needsReconnectIds || []).map(id => id.toLowerCase())), [needsReconnectIds]);
41
+
42
+ const list = useMemo(() => {
43
+ const arr = [...providers];
44
+ if (!sort) return arr;
45
+ return arr.sort((a, b) => {
46
+ const pr = byPriority(a.id, b.id);
47
+ if (pr !== 0) return pr;
48
+ return a.name.localeCompare(b.name);
49
+ });
50
+ }, [providers, sort]);
51
+
52
+ const getRedirectUri = (providerId: string) => {
53
+ if (redirectUriOverrides && redirectUriOverrides[providerId as keyof typeof redirectUriOverrides]) {
54
+ return redirectUriOverrides[providerId as keyof typeof redirectUriOverrides] as string;
55
+ }
56
+ if (typeof window !== 'undefined') {
57
+ const origin = window.location.origin;
58
+ return `${origin}/api/auth/${providerId}/callback`;
59
+ }
60
+ return '';
61
+ };
62
+
63
+ const buildState = () => {
64
+ const baseStateObj: Record<string, string> = {};
65
+ if (companyId) baseStateObj.companyId = companyId;
66
+ const extra = typeof buildOAuthState === 'function' ? buildOAuthState() : undefined;
67
+ if (typeof extra === 'string') return extra;
68
+ const obj = { ...baseStateObj, ...(extra || {}) } as Record<string, string | number | boolean>;
69
+ // default to JSON string for safety across providers
70
+ try { return JSON.stringify(obj); } catch { return ''; }
71
+ };
72
+
73
+ const onDisconnect = async (providerId: string) => {
74
+ try {
75
+ const res = await fetch(`${apiBase}/user/disconnect`, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'Authorization': `Bearer ${idToken}`,
80
+ },
81
+ body: JSON.stringify({ provider: providerId, companyId })
82
+ });
83
+ const data = await res.json().catch(() => ({}));
84
+ if (!res.ok) throw new Error(data?.error || `Failed to disconnect ${providerId}.`);
85
+ if (onDisconnected) onDisconnected(providerId);
86
+ } catch (e) {
87
+ const err = e as Error;
88
+ if (onError) onError(err.message || 'Failed to disconnect');
89
+ }
90
+ };
91
+
92
+ const onSubmitLink = async (providerId: string) => {
93
+ const provider = providers.find(p => p.id.toLowerCase() === providerId.toLowerCase());
94
+ if (!provider || !provider.endpoint) return;
95
+ if (!linkUrl) return onError && onError(`Please enter your ${provider.name} profile URL.`);
96
+ try {
97
+ setIsSubmitting(true);
98
+ const urlToSend = provider.id.toLowerCase() === 'linkedin' ? normalizeLinkedInInput(linkUrl) : linkUrl;
99
+ const res = await fetch(`${apiBase}${provider.endpoint}`, {
100
+ method: 'POST',
101
+ headers: {
102
+ 'Content-Type': 'application/json',
103
+ 'Authorization': `Bearer ${idToken}`,
104
+ },
105
+ body: JSON.stringify(companyId ? { profileUrl: urlToSend, companyId } : { profileUrl: urlToSend })
106
+ });
107
+ const data = await res.json().catch(() => ({}));
108
+ if (!res.ok) throw new Error(data?.error || `Failed to sync ${provider.name} profile.`);
109
+ setSelectedProviderId(null);
110
+ setLinkUrl('');
111
+ if (onConnected) onConnected(providerId);
112
+ } catch (e) {
113
+ const err = e as Error;
114
+ if (onError) onError(err.message || 'Failed to connect');
115
+ } finally {
116
+ setIsSubmitting(false);
117
+ }
118
+ };
119
+
120
+ const onOAuth = (providerId: string) => {
121
+ const id = providerId.toLowerCase();
122
+ const redirectUri = getRedirectUri(id);
123
+ const state = buildState();
124
+ // Lazy import builder to avoid circular deps
125
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
126
+ const { buildOAuthUrl } = require('./oauth');
127
+ const url = buildOAuthUrl({ providerId: id, redirectUri, state });
128
+ if (url) router.push(url);
129
+ };
130
+
131
+ return (
132
+ <div className={className}>
133
+ {(() => {
134
+ const oauthList = list.filter(p => p.connectionType === 'oauth');
135
+ const urlList = list.filter(p => (p.connectionType || 'url') === 'url' || p.connectionType === 'link');
136
+
137
+ const Row = (provider: typeof list[number]) => {
138
+ const providerId = provider.id;
139
+ const isConnected = connectedIds.has(providerId.toLowerCase());
140
+ const needsReconnect = reconnectIds.has(providerId.toLowerCase());
141
+ const isUrl = (provider.connectionType || 'url') === 'url' || provider.connectionType === 'link';
142
+ const isOauth = provider.connectionType === 'oauth';
143
+ return (
144
+ <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">
145
+ <div className="flex items-center gap-3">
146
+ <ProviderIcon name={providerId} className={`sm:size-7 size-5 ${provider.iconColor || 'text-gray-500'}`} />
147
+ <span className="font-medium sm:text-base text-sm" style={{ color: 'var(--text-main)'}}>{provider.name}</span>
148
+ {provider.beta ? (
149
+ <span
150
+ 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"
151
+ style={{ verticalAlign: 'middle', letterSpacing: '0.05em' }}
152
+ >
153
+ Beta
154
+ </span>
155
+ ) : null}
156
+ </div>
157
+
158
+ {isConnected ? (
159
+ <div className="relative flex items-center">
160
+ <div className="flex items-center gap-2 transition-opacity group-hover:opacity-0" style={{ color: 'var(--success-green)'}}>
161
+ {/* CheckCircle icon (inline SVG) */}
162
+ <CheckCircle className="size-3 sm:size-4" />
163
+ <span className="text-sm font-medium">Connected</span>
164
+ </div>
165
+ <div className="absolute right-0 opacity-0 transition-opacity group-hover:opacity-100">
166
+ <Button
167
+ onClick={() => onDisconnect(providerId)}
168
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 hover:underline"
169
+ >
170
+ {/* Unlink icon (inline SVG) */}
171
+ <Unlink className="size-3 sm:size-4" />
172
+ <span>Disconnect</span>
173
+ </Button>
174
+ </div>
175
+ </div>
176
+ ) : (
177
+ <div className="flex items-center gap-2">
178
+ {selectedProviderId === providerId && isUrl ? (
179
+ <form onSubmit={(e) => { e.preventDefault(); onSubmitLink(providerId); }} className="flex items-center gap-2">
180
+ <div className="relative">
181
+ <Input
182
+ type="url"
183
+ value={linkUrl}
184
+ onChange={(e) => setLinkUrl(e.target.value)}
185
+ placeholder={provider.placeholder || 'https://example.com/your-profile'}
186
+ required
187
+ className="w-72 border bg-transparent p-2 text-sm rounded"
188
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
189
+ onPaste={providerId === 'linkedin' ? (e) => { const text = e.clipboardData.getData('text'); setLinkUrl(normalizeLinkedInInput(text)); e.preventDefault(); } : undefined}
190
+ onBlur={providerId === 'linkedin' ? (() => setLinkUrl(normalizeLinkedInInput(linkUrl))) : undefined}
191
+ />
192
+ </div>
193
+ <Button type="submit" disabled={isSubmitting} className="px-3 py-2 rounded bg-[var(--icon-accent)] text-white">
194
+ {isSubmitting ? 'Connecting…' : 'Connect'}
195
+ </Button>
196
+ </form>
197
+ ) : (
198
+ <>
199
+ <Button
200
+ onClick={() => (isOauth ? onOAuth(providerId) : setSelectedProviderId(providerId))}
201
+ 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"
202
+ >
203
+ {/* OAuth/URL leading icon */}
204
+ {isOauth ? (
205
+ <Link2 className="size-3 sm:size-4" />
206
+ ) : (
207
+ <LinkIcon className="size-3 sm:size-4" />
208
+ )}
209
+ <span className="sm:text-base text-sm">Connect</span>
210
+ </Button>
211
+ {needsReconnect && (
212
+ <Button
213
+ onClick={() => onDisconnect(providerId)}
214
+ className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-sm rounded border"
215
+ style={{ color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)'}}
216
+ >
217
+ {/* Unlink icon (inline SVG) */}
218
+ <Unlink className="size-3 sm:size-4" />
219
+ <span>Remove</span>
220
+ </Button>
221
+ )}
222
+ </>
223
+ )}
224
+ </div>
225
+ )}
226
+ </div>
227
+ );
228
+ };
229
+
230
+ return (
231
+ <>
232
+ {oauthList.length ? (
233
+ <>
234
+ <div className="flex items-center my-2">
235
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
236
+ <span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>OAuth</span>
237
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
238
+ </div>
239
+ {oauthList.map(p => Row(p))}
240
+ </>
241
+ ) : null}
242
+
243
+ {urlList.length ? (
244
+ <>
245
+ <div className="flex items-center my-2">
246
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
247
+ <span className="mx-3 text-[10px] sm:text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)'}}>Public Urls</span>
248
+ <div className="flex-1 border-t" style={{ borderColor: 'var(--icon-button-secondary)', opacity: 0.3 }} />
249
+ </div>
250
+ {urlList.map(p => Row(p))}
251
+ </>
252
+ ) : null}
253
+ </>
254
+ );
255
+ })()}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ export default ConnectAccounts;
261
+
262
+
@@ -0,0 +1,48 @@
1
+ export function normalizeLinkedInInput(raw: string): string {
2
+ let working = (raw || '').trim();
3
+ if (!working) return '';
4
+ working = working.replace(/^['"]|['"]$/g, '');
5
+ const lower = working.toLowerCase();
6
+
7
+ const buildFromHandle = (handleRaw: string) => {
8
+ let handle = (handleRaw || '').trim();
9
+ handle = handle.replace(/^@+/, '');
10
+ handle = handle.replace(/^in\//i, '');
11
+ handle = handle.replace(/^\/+/, '');
12
+ handle = handle.split('?')[0].split('#')[0];
13
+ handle = handle.replace(/[^a-zA-Z0-9._-]/g, '-');
14
+ handle = handle.replace(/-+/g, '-').replace(/^-+|-+$/g, '');
15
+ return handle ? `https://www.linkedin.com/in/${handle}` : 'https://www.linkedin.com/';
16
+ };
17
+
18
+ if (lower.includes('linkedin.com')) {
19
+ let urlStr = working;
20
+ if (!/^https?:\/\//i.test(urlStr)) {
21
+ urlStr = 'https://' + urlStr.replace(/^\/+/, '');
22
+ }
23
+ try {
24
+ const u = new URL(urlStr);
25
+ const parts = (u.pathname || '').split('/').filter(Boolean);
26
+ const reserved = new Set(['pub', 'mwlite', 'profile', 'redir', 'sales', 'company', 'school', 'feed', 'posts', 'pulse']);
27
+ let handle = '';
28
+ const inIndex = parts.findIndex(p => p.toLowerCase() === 'in');
29
+ if (inIndex >= 0 && parts[inIndex + 1]) {
30
+ handle = parts[inIndex + 1];
31
+ } else {
32
+ const candidates = parts.filter(p => !reserved.has(p.toLowerCase()));
33
+ handle = candidates[candidates.length - 1] || '';
34
+ }
35
+ return buildFromHandle(handle);
36
+ } catch {
37
+ // fall through to handle mode
38
+ }
39
+ }
40
+
41
+ if (/^https?:\/\//i.test(working) || /^www\./i.test(working)) {
42
+ return working;
43
+ }
44
+
45
+ return buildFromHandle(working);
46
+ }
47
+
48
+
@@ -0,0 +1,44 @@
1
+ type KnownProvider = 'github' | 'gitlab' | 'stackoverflow';
2
+
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; }
6
+ };
7
+
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
+
14
+ const DEFAULT_SCOPES: Record<KnownProvider, string> = {
15
+ github: 'read:user,repo',
16
+ gitlab: 'read_api read_user read_repository openid profile email',
17
+ stackoverflow: '',
18
+ };
19
+
20
+ export function buildOAuthUrl(params: {
21
+ providerId: KnownProvider | string;
22
+ redirectUri: string;
23
+ state?: string;
24
+ scopeOverride?: string;
25
+ }): string {
26
+ const id = (params.providerId || '').toLowerCase() as KnownProvider;
27
+ const clientId = CLIENT_IDS[id as KnownProvider];
28
+ const scope = params.scopeOverride ?? DEFAULT_SCOPES[id as KnownProvider] ?? '';
29
+ const redirect = encodeURIComponent(params.redirectUri);
30
+ const state = params.state ? `&state=${encodeURIComponent(params.state)}` : '';
31
+
32
+ if (id === 'github') {
33
+ return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirect}${scope ? `&scope=${encodeURIComponent(scope)}` : ''}${state}`;
34
+ }
35
+ if (id === 'gitlab') {
36
+ return `https://gitlab.com/oauth/authorize?client_id=${clientId}&redirect_uri=${redirect}&response_type=code&scope=${encodeURIComponent(scope)}${state}`;
37
+ }
38
+ if (id === 'stackoverflow') {
39
+ return `https://stackoverflow.com/oauth?client_id=${clientId}&redirect_uri=${redirect}${scope ? `&scope=${encodeURIComponent(scope)}` : ''}${state}`;
40
+ }
41
+ return '';
42
+ }
43
+
44
+
@@ -0,0 +1,36 @@
1
+ export type ConnectedAccountLite = {
2
+ id: string;
3
+ url?: string | null;
4
+ };
5
+
6
+ export type RedirectUriOverrides = Partial<Record<'github' | 'gitlab' | 'stackoverflow', string>>;
7
+
8
+ export type StateFormat = 'query' | 'json';
9
+
10
+ export interface ConnectAccountsProps {
11
+ providers: Array<{
12
+ id: string;
13
+ name: string;
14
+ connectionType?: 'oauth' | 'url' | 'link';
15
+ iconColor?: string;
16
+ endpoint?: string; // required for url/link providers
17
+ placeholder?: string;
18
+ copy?: string;
19
+ beta?: boolean;
20
+ }>;
21
+ connected: ConnectedAccountLite[];
22
+ idToken: string;
23
+ apiGatewayUrl?: string;
24
+ companyId?: string;
25
+ needsReconnectIds?: string[];
26
+ sort?: boolean;
27
+ buildOAuthState?: () => Record<string, string | number | boolean> | string | undefined;
28
+ stateFormat?: StateFormat;
29
+ redirectUriOverrides?: RedirectUriOverrides;
30
+ onConnected?: (providerId: string) => void;
31
+ onDisconnected?: (providerId: string) => void;
32
+ onError?: (message: string) => void;
33
+ className?: string;
34
+ }
35
+
36
+
package/src/index.ts CHANGED
@@ -8,4 +8,7 @@ export { getBadgeImageUrl } from './components/ReportHeader';
8
8
 
9
9
  export * from './components/charts/ChartTooltip';
10
10
  export * from './components/charts/ChartEmptyState';
11
- export * from './components/charts/ChartLegend';
11
+ export * from './components/charts/ChartLegend';
12
+ export { default as ConnectAccounts } from './connect/ConnectAccounts';
13
+ export * from './connect/types';
14
+ export { normalizeLinkedInInput } from './connect/linkedin';
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@ export type Provider = {
2
2
  id: string;
3
3
  name: string;
4
4
  authUrl?: string;
5
- connectionType?: 'oauth' | 'url';
5
+ connectionType?: 'oauth' | 'url' | 'link';
6
6
  iconColor?: string;
7
7
  colors: {
8
8
  bg: string;
@@ -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
+