@open-slide/core 0.0.1

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.
@@ -0,0 +1,57 @@
1
+ import { cn } from '@/lib/utils';
2
+ import { ScrollArea } from '@/components/ui/scroll-area';
3
+ import type { SlidePage } from '../lib/sdk';
4
+ import { SlideCanvas } from './SlideCanvas';
5
+ import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../lib/sdk';
6
+
7
+ type Props = {
8
+ pages: SlidePage[];
9
+ current: number;
10
+ onSelect: (index: number) => void;
11
+ };
12
+
13
+ const THUMB_WIDTH = 200;
14
+ const THUMB_SCALE = THUMB_WIDTH / CANVAS_WIDTH;
15
+ const THUMB_HEIGHT = CANVAS_HEIGHT * THUMB_SCALE;
16
+
17
+ export function ThumbnailRail({ pages, current, onSelect }: Props) {
18
+ return (
19
+ <ScrollArea className="h-full border-r bg-card">
20
+ <aside className="flex flex-col gap-2.5 p-3">
21
+ {pages.map((Page, i) => {
22
+ const active = i === current;
23
+ return (
24
+ <button
25
+ key={i}
26
+ onClick={() => onSelect(i)}
27
+ aria-label={`Go to slide ${i + 1}`}
28
+ aria-current={active ? 'true' : undefined}
29
+ className={cn(
30
+ 'flex items-center gap-2.5 rounded-lg border-2 border-transparent p-1.5 text-left transition-colors',
31
+ 'hover:bg-muted',
32
+ active && 'border-primary bg-primary/5',
33
+ )}
34
+ >
35
+ <span
36
+ className={cn(
37
+ 'w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground',
38
+ active && 'font-semibold text-primary',
39
+ )}
40
+ >
41
+ {i + 1}
42
+ </span>
43
+ <div
44
+ className="relative shrink-0 overflow-hidden rounded border bg-white shadow-sm"
45
+ style={{ width: THUMB_WIDTH, height: THUMB_HEIGHT }}
46
+ >
47
+ <SlideCanvas scale={THUMB_SCALE} center={false} flat>
48
+ <Page />
49
+ </SlideCanvas>
50
+ </div>
51
+ </button>
52
+ );
53
+ })}
54
+ </aside>
55
+ </ScrollArea>
56
+ );
57
+ }
@@ -0,0 +1,102 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useInspector } from './InspectorProvider';
4
+
5
+ const POPOVER_W = 320;
6
+ const POPOVER_H = 180;
7
+
8
+ export function CommentPopover() {
9
+ const { pending, setPending, add } = useInspector();
10
+ const [text, setText] = useState('');
11
+ const [submitting, setSubmitting] = useState(false);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const taRef = useRef<HTMLTextAreaElement>(null);
14
+
15
+ useEffect(() => {
16
+ taRef.current?.focus();
17
+ }, []);
18
+
19
+ useEffect(() => {
20
+ const onKey = (e: KeyboardEvent) => {
21
+ if (e.key === 'Escape') setPending(null);
22
+ };
23
+ window.addEventListener('keydown', onKey);
24
+ return () => window.removeEventListener('keydown', onKey);
25
+ }, [setPending]);
26
+
27
+ if (!pending) return null;
28
+
29
+ const left = clamp(pending.clickX + 12, 8, window.innerWidth - POPOVER_W - 8);
30
+ const top = clamp(pending.clickY + 12, 8, window.innerHeight - POPOVER_H - 8);
31
+
32
+ const onSubmit = async () => {
33
+ const trimmed = text.trim();
34
+ if (!trimmed) return;
35
+ setSubmitting(true);
36
+ try {
37
+ await add(pending.line, pending.column, trimmed);
38
+ setPending(null);
39
+ } catch (e) {
40
+ setError(String((e as Error).message ?? e));
41
+ setSubmitting(false);
42
+ }
43
+ };
44
+
45
+ return createPortal(
46
+ <div
47
+ data-inspector-ui
48
+ className="fixed z-50 rounded-md border bg-card p-3 shadow-xl"
49
+ style={{ left, top, width: POPOVER_W }}
50
+ >
51
+ <div className="mb-2 flex items-center justify-between">
52
+ <span className="text-xs font-medium text-muted-foreground">
53
+ Line {pending.line} · Comment
54
+ </span>
55
+ <button
56
+ type="button"
57
+ className="text-xs text-muted-foreground hover:text-foreground"
58
+ onClick={() => setPending(null)}
59
+ >
60
+
61
+ </button>
62
+ </div>
63
+ <textarea
64
+ ref={taRef}
65
+ value={text}
66
+ onChange={(e) => setText(e.target.value)}
67
+ placeholder="Describe the change…"
68
+ className="h-20 w-full resize-none rounded border bg-background p-2 text-sm outline-none focus:ring-2 focus:ring-primary/40"
69
+ onKeyDown={(e) => {
70
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
71
+ e.preventDefault();
72
+ onSubmit();
73
+ }
74
+ }}
75
+ />
76
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
77
+ <div className="mt-2 flex items-center justify-end gap-2">
78
+ <button
79
+ type="button"
80
+ onClick={() => setPending(null)}
81
+ className="rounded border px-2 py-1 text-xs hover:bg-muted"
82
+ >
83
+ Cancel
84
+ </button>
85
+ <button
86
+ type="button"
87
+ disabled={submitting || !text.trim()}
88
+ onClick={onSubmit}
89
+ className="rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground disabled:opacity-50"
90
+ >
91
+ {submitting ? 'Saving…' : 'Submit'}
92
+ </button>
93
+ </div>
94
+ <p className="mt-2 text-[10px] text-muted-foreground">⌘/Ctrl + Enter to submit</p>
95
+ </div>,
96
+ document.body,
97
+ );
98
+ }
99
+
100
+ function clamp(n: number, lo: number, hi: number): number {
101
+ return Math.max(lo, Math.min(hi, n));
102
+ }
@@ -0,0 +1,63 @@
1
+ import { MessageSquare, Trash2, X } from 'lucide-react';
2
+ import { useState } from 'react';
3
+ import { useInspector } from './InspectorProvider';
4
+
5
+ export function CommentWidget() {
6
+ const { comments, remove, error } = useInspector();
7
+ const [open, setOpen] = useState(false);
8
+ const count = comments.length;
9
+
10
+ return (
11
+ <div data-inspector-ui className="fixed right-4 bottom-4 z-40">
12
+ {open && (
13
+ <div className="mb-2 w-80 rounded-md border bg-card shadow-xl">
14
+ <div className="flex items-center justify-between border-b px-3 py-2">
15
+ <span className="text-xs font-semibold">
16
+ {count} comment{count === 1 ? '' : 's'}
17
+ </span>
18
+ <button
19
+ type="button"
20
+ className="text-muted-foreground hover:text-foreground"
21
+ onClick={() => setOpen(false)}
22
+ >
23
+ <X className="size-3.5" />
24
+ </button>
25
+ </div>
26
+ {error && <p className="px-3 py-2 text-xs text-red-600">{error}</p>}
27
+ {count === 0 ? (
28
+ <p className="px-3 py-6 text-center text-xs text-muted-foreground">
29
+ No comments yet. Toggle Inspect and click a slide element.
30
+ </p>
31
+ ) : (
32
+ <ul className="max-h-72 overflow-auto">
33
+ {comments.map((c) => (
34
+ <li key={c.id} className="flex items-start gap-2 border-b px-3 py-2 last:border-0">
35
+ <div className="min-w-0 flex-1">
36
+ <div className="text-[10px] font-mono text-muted-foreground">line {c.line}</div>
37
+ <div className="mt-0.5 text-xs break-words">{c.note}</div>
38
+ </div>
39
+ <button
40
+ type="button"
41
+ onClick={() => remove(c.id)}
42
+ className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-red-600"
43
+ title="Delete"
44
+ >
45
+ <Trash2 className="size-3.5" />
46
+ </button>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )}
51
+ </div>
52
+ )}
53
+ <button
54
+ type="button"
55
+ onClick={() => setOpen((o) => !o)}
56
+ className="flex items-center gap-2 rounded-full border bg-card px-3 py-2 text-xs font-medium shadow-lg hover:bg-muted"
57
+ >
58
+ <MessageSquare className="size-4" />
59
+ {count}
60
+ </button>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,94 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { findSlideSource, type SlideSourceHit } from '@/lib/inspector/fiber';
3
+ import { CommentPopover } from './CommentPopover';
4
+ import { useInspector } from './InspectorProvider';
5
+
6
+ type Highlight = { rect: DOMRect; hit: SlideSourceHit };
7
+
8
+ export function InspectOverlay() {
9
+ const { active, deckId, pending, setPending } = useInspector();
10
+ const overlayRef = useRef<HTMLDivElement>(null);
11
+ const [hover, setHover] = useState<Highlight | null>(null);
12
+
13
+ useEffect(() => {
14
+ if (!active) {
15
+ setHover(null);
16
+ return;
17
+ }
18
+
19
+ const onMove = (e: PointerEvent) => {
20
+ if (pending) return;
21
+ const el = pickElement(e.clientX, e.clientY);
22
+ if (!el) return setHover(null);
23
+ const hit = findSlideSource(el, deckId);
24
+ if (!hit) return setHover(null);
25
+ setHover({ rect: hit.anchor.getBoundingClientRect(), hit });
26
+ };
27
+
28
+ const onClick = (e: MouseEvent) => {
29
+ if (pending) return;
30
+ if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
31
+ const el = pickElement(e.clientX, e.clientY);
32
+ if (!el) return;
33
+ const hit = findSlideSource(el, deckId);
34
+ if (!hit) return;
35
+ e.preventDefault();
36
+ e.stopPropagation();
37
+ setPending({
38
+ line: hit.line,
39
+ column: hit.column,
40
+ anchorRect: hit.anchor.getBoundingClientRect(),
41
+ clickX: e.clientX,
42
+ clickY: e.clientY,
43
+ });
44
+ setHover(null);
45
+ };
46
+
47
+ window.addEventListener('pointermove', onMove, true);
48
+ window.addEventListener('click', onClick, true);
49
+ return () => {
50
+ window.removeEventListener('pointermove', onMove, true);
51
+ window.removeEventListener('click', onClick, true);
52
+ };
53
+ }, [active, deckId, pending, setPending]);
54
+
55
+ if (!active) return null;
56
+
57
+ const overlayRect = overlayRef.current?.getBoundingClientRect();
58
+ const show = hover && overlayRect;
59
+
60
+ return (
61
+ <>
62
+ <div
63
+ ref={overlayRef}
64
+ className="pointer-events-none absolute inset-0 z-30"
65
+ style={{ cursor: 'crosshair' }}
66
+ >
67
+ {show && (
68
+ <div
69
+ className="absolute"
70
+ style={{
71
+ left: hover.rect.left - overlayRect.left,
72
+ top: hover.rect.top - overlayRect.top,
73
+ width: hover.rect.width,
74
+ height: hover.rect.height,
75
+ outline: '2px solid #3b82f6',
76
+ background: 'rgba(59,130,246,0.1)',
77
+ }}
78
+ />
79
+ )}
80
+ </div>
81
+ {pending && <CommentPopover />}
82
+ </>
83
+ );
84
+ }
85
+
86
+ function pickElement(x: number, y: number): HTMLElement | null {
87
+ const stack = document.elementsFromPoint(x, y);
88
+ for (const el of stack) {
89
+ if (!(el instanceof HTMLElement)) continue;
90
+ if (el.closest('[data-inspector-ui]')) continue;
91
+ return el;
92
+ }
93
+ return null;
94
+ }
@@ -0,0 +1,75 @@
1
+ import { Crosshair } from 'lucide-react';
2
+ import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { type SlideComment, useComments } from '@/lib/inspector/useComments';
5
+
6
+ export type PendingTarget = {
7
+ line: number;
8
+ column: number;
9
+ anchorRect: DOMRect;
10
+ clickX: number;
11
+ clickY: number;
12
+ };
13
+
14
+ type InspectorCtx = {
15
+ deckId: string;
16
+ active: boolean;
17
+ toggle: () => void;
18
+ comments: SlideComment[];
19
+ error: string | null;
20
+ refetch: () => Promise<void>;
21
+ add: (line: number, column: number, text: string) => Promise<void>;
22
+ remove: (id: string) => Promise<void>;
23
+ pending: PendingTarget | null;
24
+ setPending: (p: PendingTarget | null) => void;
25
+ };
26
+
27
+ const Ctx = createContext<InspectorCtx | null>(null);
28
+
29
+ export function useInspector(): InspectorCtx {
30
+ const v = useContext(Ctx);
31
+ if (!v) throw new Error('useInspector must be used inside <InspectorProvider>');
32
+ return v;
33
+ }
34
+
35
+ export function InspectorProvider({ deckId, children }: { deckId: string; children: ReactNode }) {
36
+ const [active, setActive] = useState(false);
37
+ const [pending, setPending] = useState<PendingTarget | null>(null);
38
+ const { comments, error, refetch, add, remove } = useComments(deckId);
39
+
40
+ const toggle = useCallback(() => {
41
+ setActive((a) => {
42
+ if (a) setPending(null);
43
+ return !a;
44
+ });
45
+ }, []);
46
+
47
+ const value = useMemo<InspectorCtx>(
48
+ () => ({
49
+ deckId,
50
+ active,
51
+ toggle,
52
+ comments,
53
+ error,
54
+ refetch,
55
+ add,
56
+ remove,
57
+ pending,
58
+ setPending,
59
+ }),
60
+ [deckId, active, toggle, comments, error, refetch, add, remove, pending],
61
+ );
62
+
63
+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
64
+ }
65
+
66
+ export function InspectToggleButton() {
67
+ const { active, toggle } = useInspector();
68
+ if (import.meta.env.PROD) return null;
69
+ return (
70
+ <Button size="sm" variant={active ? 'default' : 'outline'} onClick={toggle} data-inspector-ui>
71
+ <Crosshair className="size-4" />
72
+ Inspect
73
+ </Button>
74
+ );
75
+ }
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { Slot } from 'radix-ui';
4
+
5
+ import { cn } from '@/lib/utils';
6
+
7
+ const badgeVariants = cva(
8
+ 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
13
+ secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
14
+ destructive:
15
+ 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
16
+ outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
17
+ ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
18
+ link: 'text-primary underline-offset-4 hover:underline',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ variant: 'default',
23
+ },
24
+ },
25
+ );
26
+
27
+ function Badge({
28
+ className,
29
+ variant = 'default',
30
+ asChild = false,
31
+ ...props
32
+ }: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
33
+ const Comp = asChild ? Slot.Root : 'span';
34
+
35
+ return (
36
+ <Comp
37
+ data-slot="badge"
38
+ data-variant={variant}
39
+ className={cn(badgeVariants({ variant }), className)}
40
+ {...props}
41
+ />
42
+ );
43
+ }
44
+
45
+ export { Badge, badgeVariants };
@@ -0,0 +1,67 @@
1
+ import * as React from 'react';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { Slot } from 'radix-ui';
4
+
5
+ import { cn } from '@/lib/utils';
6
+
7
+ const buttonVariants = cva(
8
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
13
+ outline:
14
+ 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
15
+ secondary:
16
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
17
+ ghost:
18
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
19
+ destructive:
20
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
21
+ link: 'text-primary underline-offset-4 hover:underline',
22
+ },
23
+ size: {
24
+ default:
25
+ 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
26
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
27
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
28
+ lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
29
+ icon: 'size-8',
30
+ 'icon-xs':
31
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
32
+ 'icon-sm':
33
+ 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
34
+ 'icon-lg': 'size-9',
35
+ },
36
+ },
37
+ defaultVariants: {
38
+ variant: 'default',
39
+ size: 'default',
40
+ },
41
+ },
42
+ );
43
+
44
+ function Button({
45
+ className,
46
+ variant = 'default',
47
+ size = 'default',
48
+ asChild = false,
49
+ ...props
50
+ }: React.ComponentProps<'button'> &
51
+ VariantProps<typeof buttonVariants> & {
52
+ asChild?: boolean;
53
+ }) {
54
+ const Comp = asChild ? Slot.Root : 'button';
55
+
56
+ return (
57
+ <Comp
58
+ data-slot="button"
59
+ data-variant={variant}
60
+ data-size={size}
61
+ className={cn(buttonVariants({ variant, size, className }))}
62
+ {...props}
63
+ />
64
+ );
65
+ }
66
+
67
+ export { Button, buttonVariants };
@@ -0,0 +1,92 @@
1
+ import * as React from 'react';
2
+
3
+ import { cn } from '@/lib/utils';
4
+
5
+ function Card({
6
+ className,
7
+ size = 'default',
8
+ ...props
9
+ }: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) {
10
+ return (
11
+ <div
12
+ data-slot="card"
13
+ data-size={size}
14
+ className={cn(
15
+ 'group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
16
+ className,
17
+ )}
18
+ {...props}
19
+ />
20
+ );
21
+ }
22
+
23
+ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
24
+ return (
25
+ <div
26
+ data-slot="card-header"
27
+ className={cn(
28
+ 'group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3',
29
+ className,
30
+ )}
31
+ {...props}
32
+ />
33
+ );
34
+ }
35
+
36
+ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
37
+ return (
38
+ <div
39
+ data-slot="card-title"
40
+ className={cn(
41
+ 'font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm',
42
+ className,
43
+ )}
44
+ {...props}
45
+ />
46
+ );
47
+ }
48
+
49
+ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
50
+ return (
51
+ <div
52
+ data-slot="card-description"
53
+ className={cn('text-sm text-muted-foreground', className)}
54
+ {...props}
55
+ />
56
+ );
57
+ }
58
+
59
+ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
60
+ return (
61
+ <div
62
+ data-slot="card-action"
63
+ className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
64
+ {...props}
65
+ />
66
+ );
67
+ }
68
+
69
+ function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
70
+ return (
71
+ <div
72
+ data-slot="card-content"
73
+ className={cn('px-4 group-data-[size=sm]/card:px-3', className)}
74
+ {...props}
75
+ />
76
+ );
77
+ }
78
+
79
+ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
80
+ return (
81
+ <div
82
+ data-slot="card-footer"
83
+ className={cn(
84
+ 'flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3',
85
+ className,
86
+ )}
87
+ {...props}
88
+ />
89
+ );
90
+ }
91
+
92
+ export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
@@ -0,0 +1,53 @@
1
+ import * as React from 'react';
2
+ import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui';
3
+
4
+ import { cn } from '@/lib/utils';
5
+
6
+ function ScrollArea({
7
+ className,
8
+ children,
9
+ ...props
10
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
11
+ return (
12
+ <ScrollAreaPrimitive.Root
13
+ data-slot="scroll-area"
14
+ className={cn('relative', className)}
15
+ {...props}
16
+ >
17
+ <ScrollAreaPrimitive.Viewport
18
+ data-slot="scroll-area-viewport"
19
+ className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
20
+ >
21
+ {children}
22
+ </ScrollAreaPrimitive.Viewport>
23
+ <ScrollBar />
24
+ <ScrollAreaPrimitive.Corner />
25
+ </ScrollAreaPrimitive.Root>
26
+ );
27
+ }
28
+
29
+ function ScrollBar({
30
+ className,
31
+ orientation = 'vertical',
32
+ ...props
33
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
34
+ return (
35
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
36
+ data-slot="scroll-area-scrollbar"
37
+ data-orientation={orientation}
38
+ orientation={orientation}
39
+ className={cn(
40
+ 'flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent',
41
+ className,
42
+ )}
43
+ {...props}
44
+ >
45
+ <ScrollAreaPrimitive.ScrollAreaThumb
46
+ data-slot="scroll-area-thumb"
47
+ className="relative flex-1 rounded-full bg-border"
48
+ />
49
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
50
+ );
51
+ }
52
+
53
+ export { ScrollArea, ScrollBar };
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Separator as SeparatorPrimitive } from 'radix-ui';
5
+
6
+ import { cn } from '@/lib/utils';
7
+
8
+ function Separator({
9
+ className,
10
+ orientation = 'horizontal',
11
+ decorative = true,
12
+ ...props
13
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
14
+ return (
15
+ <SeparatorPrimitive.Root
16
+ data-slot="separator"
17
+ decorative={decorative}
18
+ orientation={orientation}
19
+ className={cn(
20
+ 'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
21
+ className,
22
+ )}
23
+ {...props}
24
+ />
25
+ );
26
+ }
27
+
28
+ export { Separator };