@open-slide/core 0.0.7 → 0.0.9

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.
Files changed (32) hide show
  1. package/dist/{build-cUKUY4bh.js → build-pqF4W1Yi.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-DOcMmFJ7.js → config-CtwxMYv9.js} +375 -45
  4. package/dist/{dev-Brzmgu64.js → dev-CJX97uiy.js} +1 -1
  5. package/dist/{preview-Bf8iFXA-.js → preview-IuLPcL5y.js} +1 -1
  6. package/dist/vite/index.js +1 -1
  7. package/package.json +3 -1
  8. package/src/app/App.tsx +2 -0
  9. package/src/app/components/PdfProgressToast.tsx +23 -0
  10. package/src/app/components/Player.tsx +18 -3
  11. package/src/app/components/inspector/CommentWidget.tsx +1 -1
  12. package/src/app/components/inspector/InspectOverlay.tsx +81 -41
  13. package/src/app/components/inspector/InspectorPanel.tsx +805 -0
  14. package/src/app/components/inspector/InspectorProvider.tsx +199 -13
  15. package/src/app/components/inspector/SaveBar.tsx +77 -0
  16. package/src/app/components/ui/input.tsx +21 -0
  17. package/src/app/components/ui/label.tsx +24 -0
  18. package/src/app/components/ui/progress.tsx +31 -0
  19. package/src/app/components/ui/select.tsx +190 -0
  20. package/src/app/components/ui/slider.tsx +61 -0
  21. package/src/app/components/ui/sonner.tsx +38 -0
  22. package/src/app/components/ui/textarea.tsx +18 -0
  23. package/src/app/components/ui/toggle-group.tsx +83 -0
  24. package/src/app/components/ui/toggle.tsx +45 -0
  25. package/src/app/components/ui/tooltip.tsx +55 -0
  26. package/src/app/lib/export-pdf.ts +197 -0
  27. package/src/app/lib/inspector/fiber.ts +40 -5
  28. package/src/app/lib/inspector/useEditor.ts +61 -0
  29. package/src/app/lib/print-ready.ts +58 -0
  30. package/src/app/lib/useWheelPageNavigation.ts +92 -0
  31. package/src/app/routes/Slide.tsx +91 -6
  32. package/src/app/components/inspector/CommentPopover.tsx +0 -94
@@ -1,14 +1,33 @@
1
1
  import { Crosshair } from 'lucide-react';
2
- import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
2
+ import {
3
+ createContext,
4
+ type ReactNode,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
3
12
  import { Button } from '@/components/ui/button';
4
13
  import { type SlideComment, useComments } from '@/lib/inspector/useComments';
14
+ import { type Edit, type EditOp, useEditor } from '@/lib/inspector/useEditor';
5
15
 
6
- export type PendingTarget = {
16
+ export type SelectedTarget = {
7
17
  line: number;
8
18
  column: number;
9
- anchorRect: DOMRect;
10
- clickX: number;
11
- clickY: number;
19
+ anchor: HTMLElement;
20
+ };
21
+
22
+ type Bucket = {
23
+ line: number;
24
+ column: number;
25
+ styleOps: Map<string, string | null>;
26
+ textOp: { value: string } | null;
27
+ // Pre-edit snapshot of the DOM, captured the first time we touch
28
+ // each style key / text. Used by `cancelEdits` to revert.
29
+ origStyle: Map<string, string>;
30
+ origText: { value: string } | null;
12
31
  };
13
32
 
14
33
  type InspectorCtx = {
@@ -21,8 +40,18 @@ type InspectorCtx = {
21
40
  refetch: () => Promise<void>;
22
41
  add: (line: number, column: number, text: string) => Promise<void>;
23
42
  remove: (id: string) => Promise<void>;
24
- pending: PendingTarget | null;
25
- setPending: (p: PendingTarget | null) => void;
43
+ selected: SelectedTarget | null;
44
+ setSelected: (s: SelectedTarget | null) => void;
45
+ applyEdit: (line: number, column: number, ops: EditOp[]) => Promise<void>;
46
+ applyEdits: (edits: Edit[]) => Promise<void>;
47
+ // Mutate the DOM optimistically, snapshot the pre-edit values, and
48
+ // remember the ops. `commitEdits` (manual Save or auto-flush on
49
+ // close) is what actually writes to disk; `cancelEdits` reverts.
50
+ bufferOps: (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => void;
51
+ pendingCount: number;
52
+ commitEdits: () => Promise<void>;
53
+ cancelEdits: () => void;
54
+ committing: boolean;
26
55
  };
27
56
 
28
57
  const Ctx = createContext<InspectorCtx | null>(null);
@@ -35,19 +64,151 @@ export function useInspector(): InspectorCtx {
35
64
 
36
65
  export function InspectorProvider({ slideId, children }: { slideId: string; children: ReactNode }) {
37
66
  const [active, setActive] = useState(false);
38
- const [pending, setPending] = useState<PendingTarget | null>(null);
67
+ const [selected, setSelected] = useState<SelectedTarget | null>(null);
39
68
  const { comments, error, refetch, add, remove } = useComments(slideId);
69
+ const { applyEdit, applyEdits } = useEditor(slideId);
70
+
71
+ const pendingRef = useRef<Map<string, Bucket>>(new Map());
72
+ const [pendingCount, setPendingCount] = useState(0);
73
+ const [committing, setCommitting] = useState(false);
74
+
75
+ const refreshCount = useCallback(() => {
76
+ let n = 0;
77
+ for (const b of pendingRef.current.values()) {
78
+ if (b.styleOps.size > 0 || b.textOp !== null) n++;
79
+ }
80
+ setPendingCount(n);
81
+ }, []);
82
+
83
+ const bufferOps = useCallback(
84
+ (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => {
85
+ const key = `${line}:${column}`;
86
+ let bucket = pendingRef.current.get(key);
87
+ if (!bucket) {
88
+ bucket = {
89
+ line,
90
+ column,
91
+ styleOps: new Map(),
92
+ textOp: null,
93
+ origStyle: new Map(),
94
+ origText: null,
95
+ };
96
+ pendingRef.current.set(key, bucket);
97
+ }
98
+ const style = anchor.style as unknown as Record<string, string>;
99
+ for (const op of ops) {
100
+ if (op.kind === 'set-style') {
101
+ if (!bucket.origStyle.has(op.key)) {
102
+ bucket.origStyle.set(op.key, style[op.key] ?? '');
103
+ }
104
+ bucket.styleOps.set(op.key, op.value);
105
+ if (anchor.isConnected) style[op.key] = op.value ?? '';
106
+ } else if (op.kind === 'set-text') {
107
+ if (bucket.origText === null) {
108
+ bucket.origText = { value: anchor.textContent ?? '' };
109
+ }
110
+ bucket.textOp = { value: op.value };
111
+ if (anchor.isConnected) anchor.textContent = op.value;
112
+ }
113
+ }
114
+ refreshCount();
115
+ },
116
+ [refreshCount],
117
+ );
118
+
119
+ const commitEdits = useCallback(async () => {
120
+ const buckets = pendingRef.current;
121
+ if (buckets.size === 0) return;
122
+ const edits: Edit[] = [];
123
+ for (const { line, column, styleOps, textOp } of buckets.values()) {
124
+ const list: EditOp[] = [];
125
+ for (const [k, v] of styleOps) list.push({ kind: 'set-style', key: k, value: v });
126
+ if (textOp !== null) list.push({ kind: 'set-text', value: textOp.value });
127
+ if (list.length > 0) edits.push({ line, column, ops: list });
128
+ }
129
+ pendingRef.current = new Map();
130
+ setPendingCount(0);
131
+ if (edits.length === 0) return;
132
+ setCommitting(true);
133
+ try {
134
+ await applyEdits(edits);
135
+ } finally {
136
+ setCommitting(false);
137
+ }
138
+ }, [applyEdits]);
139
+
140
+ const cancelEdits = useCallback(() => {
141
+ if (pendingRef.current.size === 0) return;
142
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
143
+ for (const b of pendingRef.current.values()) {
144
+ const el = root?.querySelector<HTMLElement>(`[data-slide-loc="${b.line}:${b.column}"]`);
145
+ if (!el) continue;
146
+ const style = el.style as unknown as Record<string, string>;
147
+ for (const [k, v] of b.origStyle) style[k] = v;
148
+ if (b.origText !== null) el.textContent = b.origText.value;
149
+ }
150
+ pendingRef.current = new Map();
151
+ setPendingCount(0);
152
+ }, []);
153
+
154
+ // Auto-flush on inspector close and on route unmount so toggling
155
+ // off or navigating away doesn't drop buffered edits.
156
+ const commitRef = useRef(commitEdits);
157
+ commitRef.current = commitEdits;
158
+ useEffect(() => {
159
+ if (!active) commitRef.current().catch(() => {});
160
+ }, [active]);
161
+ useEffect(() => {
162
+ return () => {
163
+ commitRef.current().catch(() => {});
164
+ };
165
+ }, []);
166
+
167
+ // Re-apply buffered ops onto any `[data-slide-loc]` element that gets
168
+ // (re)mounted in the slide canvas. Without this, navigating to a
169
+ // different page and back drops the optimistic styles, since the
170
+ // page's DOM nodes are torn down on unmount even though the buffer
171
+ // (keyed by source line:col) survives.
172
+ useEffect(() => {
173
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
174
+ if (!root) return;
175
+
176
+ const applyBuffered = (el: HTMLElement) => {
177
+ const loc = el.dataset.slideLoc;
178
+ if (!loc) return;
179
+ const bucket = pendingRef.current.get(loc);
180
+ if (!bucket) return;
181
+ const style = el.style as unknown as Record<string, string>;
182
+ for (const [key, value] of bucket.styleOps) {
183
+ const v = value ?? '';
184
+ if (style[key] !== v) style[key] = v;
185
+ }
186
+ if (bucket.textOp !== null && el.textContent !== bucket.textOp.value) {
187
+ el.textContent = bucket.textOp.value;
188
+ }
189
+ };
190
+
191
+ const replayAll = () => {
192
+ if (pendingRef.current.size === 0) return;
193
+ root.querySelectorAll<HTMLElement>('[data-slide-loc]').forEach(applyBuffered);
194
+ };
195
+
196
+ replayAll();
197
+ const observer = new MutationObserver(replayAll);
198
+ observer.observe(root, { childList: true, subtree: true });
199
+ return () => observer.disconnect();
200
+ }, []);
40
201
 
41
202
  const toggle = useCallback(() => {
42
203
  setActive((a) => {
43
- if (a) setPending(null);
204
+ if (a) setSelected(null);
44
205
  return !a;
45
206
  });
46
207
  }, []);
47
208
 
48
209
  const cancel = useCallback(() => {
49
210
  setActive(false);
50
- setPending(null);
211
+ setSelected(null);
51
212
  }, []);
52
213
 
53
214
  const value = useMemo<InspectorCtx>(
@@ -61,10 +222,35 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
61
222
  refetch,
62
223
  add,
63
224
  remove,
64
- pending,
65
- setPending,
225
+ selected,
226
+ setSelected,
227
+ applyEdit,
228
+ applyEdits,
229
+ bufferOps,
230
+ pendingCount,
231
+ commitEdits,
232
+ cancelEdits,
233
+ committing,
66
234
  }),
67
- [slideId, active, toggle, cancel, comments, error, refetch, add, remove, pending],
235
+ [
236
+ slideId,
237
+ active,
238
+ toggle,
239
+ cancel,
240
+ comments,
241
+ error,
242
+ refetch,
243
+ add,
244
+ remove,
245
+ selected,
246
+ applyEdit,
247
+ applyEdits,
248
+ bufferOps,
249
+ pendingCount,
250
+ commitEdits,
251
+ cancelEdits,
252
+ committing,
253
+ ],
68
254
  );
69
255
 
70
256
  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
@@ -0,0 +1,77 @@
1
+ import { Check, Loader2, Save, Undo2 } from 'lucide-react';
2
+ import { useEffect, useState } from 'react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { useInspector } from './InspectorProvider';
5
+
6
+ // Optimistic DOM updates make the canvas *look* saved, so without
7
+ // this affordance a user could close the tab thinking their tweaks
8
+ // hit disk when they're still buffered in memory.
9
+ export function SaveBar() {
10
+ const { pendingCount, commitEdits, cancelEdits, committing } = useInspector();
11
+ const [justSaved, setJustSaved] = useState(false);
12
+
13
+ // Brief "Saved" hold so the bar's disappearance feels intentional.
14
+ useEffect(() => {
15
+ if (!justSaved) return;
16
+ const t = setTimeout(() => setJustSaved(false), 1200);
17
+ return () => clearTimeout(t);
18
+ }, [justSaved]);
19
+
20
+ const visible = pendingCount > 0 || committing || justSaved;
21
+ if (!visible) return null;
22
+
23
+ const onSave = async () => {
24
+ await commitEdits();
25
+ setJustSaved(true);
26
+ };
27
+
28
+ return (
29
+ <div
30
+ data-inspector-ui
31
+ className="pointer-events-none absolute bottom-6 left-1/2 z-30 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-200"
32
+ >
33
+ <div className="pointer-events-auto flex items-center gap-2 rounded-full border bg-card/95 py-1 pr-1 pl-3 shadow-lg backdrop-blur">
34
+ {justSaved ? (
35
+ <span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
36
+ <Check className="size-3.5 text-emerald-600" />
37
+ Saved
38
+ </span>
39
+ ) : (
40
+ <span className="text-xs font-medium text-foreground">
41
+ {pendingCount} unsaved {pendingCount === 1 ? 'change' : 'changes'}
42
+ </span>
43
+ )}
44
+ {!justSaved && (
45
+ <Button
46
+ size="sm"
47
+ variant="ghost"
48
+ className="h-7 rounded-full px-2.5 text-[11px] text-muted-foreground hover:text-foreground"
49
+ onClick={cancelEdits}
50
+ disabled={committing || pendingCount === 0}
51
+ >
52
+ <Undo2 className="size-3.5" />
53
+ Discard
54
+ </Button>
55
+ )}
56
+ <Button
57
+ size="sm"
58
+ className="h-7 rounded-full px-3 text-[11px]"
59
+ onClick={onSave}
60
+ disabled={committing || pendingCount === 0}
61
+ >
62
+ {committing ? (
63
+ <>
64
+ <Loader2 className="size-3.5 animate-spin" />
65
+ Saving
66
+ </>
67
+ ) : (
68
+ <>
69
+ <Save className="size-3.5" />
70
+ Save
71
+ </>
72
+ )}
73
+ </Button>
74
+ </div>
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,21 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
12
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
13
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ export { Input }
@@ -0,0 +1,24 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Label as LabelPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Label({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof LabelPrimitive.Root>) {
12
+ return (
13
+ <LabelPrimitive.Root
14
+ data-slot="label"
15
+ className={cn(
16
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ export { Label }
@@ -0,0 +1,31 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Progress as ProgressPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Progress({
9
+ className,
10
+ value,
11
+ ...props
12
+ }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
13
+ return (
14
+ <ProgressPrimitive.Root
15
+ data-slot="progress"
16
+ className={cn(
17
+ "relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
18
+ className
19
+ )}
20
+ {...props}
21
+ >
22
+ <ProgressPrimitive.Indicator
23
+ data-slot="progress-indicator"
24
+ className="h-full w-full flex-1 bg-primary transition-all"
25
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
26
+ />
27
+ </ProgressPrimitive.Root>
28
+ )
29
+ }
30
+
31
+ export { Progress }
@@ -0,0 +1,190 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
5
+ import { Select as SelectPrimitive } from "radix-ui"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Select({
10
+ ...props
11
+ }: React.ComponentProps<typeof SelectPrimitive.Root>) {
12
+ return <SelectPrimitive.Root data-slot="select" {...props} />
13
+ }
14
+
15
+ function SelectGroup({
16
+ ...props
17
+ }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
19
+ }
20
+
21
+ function SelectValue({
22
+ ...props
23
+ }: React.ComponentProps<typeof SelectPrimitive.Value>) {
24
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
25
+ }
26
+
27
+ function SelectTrigger({
28
+ className,
29
+ size = "default",
30
+ children,
31
+ ...props
32
+ }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
33
+ size?: "sm" | "default"
34
+ }) {
35
+ return (
36
+ <SelectPrimitive.Trigger
37
+ data-slot="select-trigger"
38
+ data-size={size}
39
+ className={cn(
40
+ "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
41
+ className
42
+ )}
43
+ {...props}
44
+ >
45
+ {children}
46
+ <SelectPrimitive.Icon asChild>
47
+ <ChevronDownIcon className="size-4 opacity-50" />
48
+ </SelectPrimitive.Icon>
49
+ </SelectPrimitive.Trigger>
50
+ )
51
+ }
52
+
53
+ function SelectContent({
54
+ className,
55
+ children,
56
+ position = "item-aligned",
57
+ align = "center",
58
+ ...props
59
+ }: React.ComponentProps<typeof SelectPrimitive.Content>) {
60
+ return (
61
+ <SelectPrimitive.Portal>
62
+ <SelectPrimitive.Content
63
+ data-slot="select-content"
64
+ className={cn(
65
+ "relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
66
+ position === "popper" &&
67
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
68
+ className
69
+ )}
70
+ position={position}
71
+ align={align}
72
+ {...props}
73
+ >
74
+ <SelectScrollUpButton />
75
+ <SelectPrimitive.Viewport
76
+ className={cn(
77
+ "p-1",
78
+ position === "popper" &&
79
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
80
+ )}
81
+ >
82
+ {children}
83
+ </SelectPrimitive.Viewport>
84
+ <SelectScrollDownButton />
85
+ </SelectPrimitive.Content>
86
+ </SelectPrimitive.Portal>
87
+ )
88
+ }
89
+
90
+ function SelectLabel({
91
+ className,
92
+ ...props
93
+ }: React.ComponentProps<typeof SelectPrimitive.Label>) {
94
+ return (
95
+ <SelectPrimitive.Label
96
+ data-slot="select-label"
97
+ className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
98
+ {...props}
99
+ />
100
+ )
101
+ }
102
+
103
+ function SelectItem({
104
+ className,
105
+ children,
106
+ ...props
107
+ }: React.ComponentProps<typeof SelectPrimitive.Item>) {
108
+ return (
109
+ <SelectPrimitive.Item
110
+ data-slot="select-item"
111
+ className={cn(
112
+ "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
113
+ className
114
+ )}
115
+ {...props}
116
+ >
117
+ <span
118
+ data-slot="select-item-indicator"
119
+ className="absolute right-2 flex size-3.5 items-center justify-center"
120
+ >
121
+ <SelectPrimitive.ItemIndicator>
122
+ <CheckIcon className="size-4" />
123
+ </SelectPrimitive.ItemIndicator>
124
+ </span>
125
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
126
+ </SelectPrimitive.Item>
127
+ )
128
+ }
129
+
130
+ function SelectSeparator({
131
+ className,
132
+ ...props
133
+ }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
134
+ return (
135
+ <SelectPrimitive.Separator
136
+ data-slot="select-separator"
137
+ className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
138
+ {...props}
139
+ />
140
+ )
141
+ }
142
+
143
+ function SelectScrollUpButton({
144
+ className,
145
+ ...props
146
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
147
+ return (
148
+ <SelectPrimitive.ScrollUpButton
149
+ data-slot="select-scroll-up-button"
150
+ className={cn(
151
+ "flex cursor-default items-center justify-center py-1",
152
+ className
153
+ )}
154
+ {...props}
155
+ >
156
+ <ChevronUpIcon className="size-4" />
157
+ </SelectPrimitive.ScrollUpButton>
158
+ )
159
+ }
160
+
161
+ function SelectScrollDownButton({
162
+ className,
163
+ ...props
164
+ }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
165
+ return (
166
+ <SelectPrimitive.ScrollDownButton
167
+ data-slot="select-scroll-down-button"
168
+ className={cn(
169
+ "flex cursor-default items-center justify-center py-1",
170
+ className
171
+ )}
172
+ {...props}
173
+ >
174
+ <ChevronDownIcon className="size-4" />
175
+ </SelectPrimitive.ScrollDownButton>
176
+ )
177
+ }
178
+
179
+ export {
180
+ Select,
181
+ SelectContent,
182
+ SelectGroup,
183
+ SelectItem,
184
+ SelectLabel,
185
+ SelectScrollDownButton,
186
+ SelectScrollUpButton,
187
+ SelectSeparator,
188
+ SelectTrigger,
189
+ SelectValue,
190
+ }
@@ -0,0 +1,61 @@
1
+ import * as React from "react"
2
+ import { Slider as SliderPrimitive } from "radix-ui"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Slider({
7
+ className,
8
+ defaultValue,
9
+ value,
10
+ min = 0,
11
+ max = 100,
12
+ ...props
13
+ }: React.ComponentProps<typeof SliderPrimitive.Root>) {
14
+ const _values = React.useMemo(
15
+ () =>
16
+ Array.isArray(value)
17
+ ? value
18
+ : Array.isArray(defaultValue)
19
+ ? defaultValue
20
+ : [min, max],
21
+ [value, defaultValue, min, max]
22
+ )
23
+
24
+ return (
25
+ <SliderPrimitive.Root
26
+ data-slot="slider"
27
+ defaultValue={defaultValue}
28
+ value={value}
29
+ min={min}
30
+ max={max}
31
+ className={cn(
32
+ "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
33
+ className
34
+ )}
35
+ {...props}
36
+ >
37
+ <SliderPrimitive.Track
38
+ data-slot="slider-track"
39
+ className={cn(
40
+ "relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
41
+ )}
42
+ >
43
+ <SliderPrimitive.Range
44
+ data-slot="slider-range"
45
+ className={cn(
46
+ "absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
47
+ )}
48
+ />
49
+ </SliderPrimitive.Track>
50
+ {Array.from({ length: _values.length }, (_, index) => (
51
+ <SliderPrimitive.Thumb
52
+ data-slot="slider-thumb"
53
+ key={index}
54
+ className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
55
+ />
56
+ ))}
57
+ </SliderPrimitive.Root>
58
+ )
59
+ }
60
+
61
+ export { Slider }
@@ -0,0 +1,38 @@
1
+ import {
2
+ CircleCheckIcon,
3
+ InfoIcon,
4
+ Loader2Icon,
5
+ OctagonXIcon,
6
+ TriangleAlertIcon,
7
+ } from "lucide-react"
8
+ import { useTheme } from "next-themes"
9
+ import { Toaster as Sonner, type ToasterProps } from "sonner"
10
+
11
+ const Toaster = ({ ...props }: ToasterProps) => {
12
+ const { theme = "system" } = useTheme()
13
+
14
+ return (
15
+ <Sonner
16
+ theme={theme as ToasterProps["theme"]}
17
+ className="toaster group"
18
+ icons={{
19
+ success: <CircleCheckIcon className="size-4" />,
20
+ info: <InfoIcon className="size-4" />,
21
+ warning: <TriangleAlertIcon className="size-4" />,
22
+ error: <OctagonXIcon className="size-4" />,
23
+ loading: <Loader2Icon className="size-4 animate-spin" />,
24
+ }}
25
+ style={
26
+ {
27
+ "--normal-bg": "var(--popover)",
28
+ "--normal-text": "var(--popover-foreground)",
29
+ "--normal-border": "var(--border)",
30
+ "--border-radius": "var(--radius)",
31
+ } as React.CSSProperties
32
+ }
33
+ {...props}
34
+ />
35
+ )
36
+ }
37
+
38
+ export { Toaster }