@vettly/react 0.1.14 → 0.1.16

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 (2) hide show
  1. package/README.md +519 -24
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # @vettly/react
2
2
 
3
- React components for content decisions. Real-time feedback as users type.
3
+ React components with real-time policy feedback and decision tracking. Same policies as your backend, instant user feedback.
4
+
5
+ ## Why Client-Side Moderation?
6
+
7
+ **Immediate feedback** - Users see policy violations as they type, not after submission.
8
+
9
+ **Same policies** - The React SDK uses your Vettly policies, ensuring consistent enforcement between client preview and server validation.
10
+
11
+ **Decision tracking** - Every client-side check returns a `decisionId` for your audit trail, just like server-side calls.
12
+
13
+ **Always validate server-side** - Client-side moderation improves UX but is not a security boundary. Always validate on the server before persisting content.
4
14
 
5
15
  ## Installation
6
16
 
@@ -15,76 +25,561 @@ import { ModeratedTextarea } from '@vettly/react'
15
25
  import '@vettly/react/styles.css'
16
26
 
17
27
  function CommentForm() {
28
+ const handleSubmit = async (content: string, decisionId: string) => {
29
+ // Submit to your API with the decision ID for audit trail
30
+ await api.createComment({
31
+ content,
32
+ moderationDecisionId: decisionId
33
+ })
34
+ }
35
+
18
36
  return (
19
37
  <ModeratedTextarea
20
38
  apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY}
21
- policy="community-safe"
39
+ policyId="community-safe"
22
40
  placeholder="Write a comment..."
23
41
  onModerationResult={(result) => {
24
- if (result.action === 'block') {
25
- // Content blocked
26
- }
42
+ console.log(`Decision: ${result.action} (${result.decisionId})`)
27
43
  }}
28
44
  />
29
45
  )
30
46
  }
31
47
  ```
32
48
 
49
+ ---
50
+
33
51
  ## Components
34
52
 
35
53
  ### ModeratedTextarea
36
54
 
55
+ A textarea with real-time content moderation and visual feedback.
56
+
37
57
  ```tsx
38
58
  <ModeratedTextarea
39
- apiKey="sk_live_..."
40
- policy="community-safe"
59
+ // Required
60
+ apiKey="pk_live_..."
61
+ policyId="community-safe"
62
+
63
+ // Content
64
+ value={content}
65
+ onChange={(value, result) => {
66
+ setContent(value)
67
+ console.log(`Action: ${result.action}`)
68
+ }}
41
69
  placeholder="Type something..."
42
- debounceMs={500}
43
- onModerationResult={(result) => console.log(result)}
70
+
71
+ // Behavior
72
+ debounceMs={500} // Delay before checking (default: 500ms)
73
+ blockUnsafe={false} // Prevent typing when content is unsafe
74
+ useFast={true} // Use fast endpoint (<100ms responses)
75
+ disabled={false}
76
+
77
+ // Feedback
78
+ showFeedback={true} // Show built-in feedback UI
79
+ customFeedback={(result) => (
80
+ <MyCustomFeedback
81
+ action={result.action}
82
+ isChecking={result.isChecking}
83
+ error={result.error}
84
+ />
85
+ )}
86
+
87
+ // Callbacks
88
+ onModerationResult={(result) => {
89
+ // Full CheckResponse with decisionId
90
+ console.log(result.decisionId)
91
+ }}
92
+ onModerationError={(error) => {
93
+ console.error('Moderation failed:', error)
94
+ }}
95
+
96
+ // Standard textarea props
97
+ className="my-textarea"
98
+ rows={4}
99
+ maxLength={1000}
44
100
  />
45
101
  ```
46
102
 
103
+ #### Props
104
+
105
+ | Prop | Type | Default | Description |
106
+ |------|------|---------|-------------|
107
+ | `apiKey` | `string` | required | Your Vettly API key |
108
+ | `policyId` | `string` | required | Policy ID to apply |
109
+ | `value` | `string` | `''` | Controlled value |
110
+ | `onChange` | `(value, result) => void` | - | Called on value change with current moderation state |
111
+ | `debounceMs` | `number` | `500` | Milliseconds to wait before checking |
112
+ | `showFeedback` | `boolean` | `true` | Show built-in feedback component |
113
+ | `blockUnsafe` | `boolean` | `false` | Prevent additional input when content is unsafe |
114
+ | `useFast` | `boolean` | `false` | Use fast endpoint for sub-100ms responses |
115
+ | `customFeedback` | `(result) => ReactNode` | - | Custom feedback component |
116
+ | `onModerationResult` | `(result) => void` | - | Called with full moderation response |
117
+ | `onModerationError` | `(error) => void` | - | Called on moderation errors |
118
+
119
+ ---
120
+
47
121
  ### ModeratedImageUpload
48
122
 
123
+ Image upload component with pre-upload moderation.
124
+
49
125
  ```tsx
50
126
  <ModeratedImageUpload
51
- apiKey="sk_live_..."
52
- policy="strict"
127
+ // Required
128
+ apiKey="pk_live_..."
129
+ policyId="strict"
130
+
131
+ // Callbacks
53
132
  onUpload={(file, result) => {
54
133
  if (result.action !== 'block') {
55
134
  uploadToServer(file)
56
135
  }
136
+ console.log(`Decision: ${result.action}`)
137
+ }}
138
+ onReject={(file, reason) => {
139
+ console.log(`Rejected: ${reason}`)
140
+ }}
141
+
142
+ // Constraints
143
+ maxSizeMB={10}
144
+ acceptedFormats={['image/jpeg', 'image/png', 'image/gif', 'image/webp']}
145
+
146
+ // Behavior
147
+ showPreview={true}
148
+ blockUnsafe={true} // Prevent upload if content violates policy
149
+ disabled={false}
150
+
151
+ // Custom preview
152
+ customPreview={({ file, preview, result, onRemove }) => (
153
+ <div>
154
+ <img src={preview} alt={file.name} />
155
+ <p>{result.action}</p>
156
+ <button onClick={onRemove}>Remove</button>
157
+ </div>
158
+ )}
159
+
160
+ // Callbacks
161
+ onModerationResult={(result) => {
162
+ console.log(`Image decision: ${result.decisionId}`)
163
+ }}
164
+ onModerationError={(error) => {
165
+ console.error('Image moderation failed:', error)
166
+ }}
167
+
168
+ className="my-upload"
169
+ />
170
+ ```
171
+
172
+ #### Props
173
+
174
+ | Prop | Type | Default | Description |
175
+ |------|------|---------|-------------|
176
+ | `apiKey` | `string` | required | Your Vettly API key |
177
+ | `policyId` | `string` | required | Policy ID to apply |
178
+ | `onUpload` | `(file, result) => void` | - | Called when image passes moderation and user confirms |
179
+ | `onReject` | `(file, reason) => void` | - | Called when image is rejected (validation or policy) |
180
+ | `maxSizeMB` | `number` | `10` | Maximum file size in MB |
181
+ | `acceptedFormats` | `string[]` | `['image/jpeg', 'image/png', 'image/gif', 'image/webp']` | Accepted MIME types |
182
+ | `showPreview` | `boolean` | `true` | Show image preview after selection |
183
+ | `blockUnsafe` | `boolean` | `true` | Prevent upload if content violates policy |
184
+ | `customPreview` | `(props) => ReactNode` | - | Custom preview component |
185
+ | `onModerationResult` | `(result) => void` | - | Called with full moderation response |
186
+ | `onModerationError` | `(error) => void` | - | Called on moderation errors |
187
+
188
+ ---
189
+
190
+ ### ModeratedVideoUpload
191
+
192
+ Video upload with frame extraction and moderation.
193
+
194
+ ```tsx
195
+ <ModeratedVideoUpload
196
+ // Required
197
+ apiKey="pk_live_..."
198
+ policyId="video-policy"
199
+
200
+ // Callbacks
201
+ onUpload={(file, result) => {
202
+ uploadVideoToServer(file)
203
+ console.log(`Video approved: ${result.action}`)
204
+ }}
205
+ onReject={(file, reason) => {
206
+ console.log(`Video rejected: ${reason}`)
207
+ }}
208
+
209
+ // Constraints
210
+ maxSizeMB={100}
211
+ maxDurationSeconds={300} // 5 minutes
212
+ acceptedFormats={['video/mp4', 'video/webm', 'video/quicktime']}
213
+
214
+ // Frame extraction
215
+ extractFramesCount={3} // Number of frames to extract for analysis
216
+
217
+ // Behavior
218
+ showPreview={true}
219
+ blockUnsafe={true}
220
+ disabled={false}
221
+
222
+ // Custom preview
223
+ customPreview={({ file, preview, duration, result, onRemove }) => (
224
+ <div>
225
+ <video src={preview} controls />
226
+ <p>Duration: {duration}s</p>
227
+ <p>Status: {result.action}</p>
228
+ <button onClick={onRemove}>Remove</button>
229
+ </div>
230
+ )}
231
+
232
+ // Callbacks
233
+ onModerationResult={(result) => {
234
+ console.log(`Video decision: ${result.decisionId}`)
235
+ }}
236
+ onModerationError={(error) => {
237
+ console.error('Video moderation failed:', error)
57
238
  }}
239
+
240
+ className="my-video-upload"
58
241
  />
59
242
  ```
60
243
 
61
- ### useModeration Hook
244
+ #### Props
245
+
246
+ | Prop | Type | Default | Description |
247
+ |------|------|---------|-------------|
248
+ | `apiKey` | `string` | required | Your Vettly API key |
249
+ | `policyId` | `string` | required | Policy ID to apply |
250
+ | `onUpload` | `(file, result) => void` | - | Called when video passes moderation |
251
+ | `onReject` | `(file, reason) => void` | - | Called when video is rejected |
252
+ | `maxSizeMB` | `number` | `100` | Maximum file size in MB |
253
+ | `maxDurationSeconds` | `number` | `300` | Maximum video duration in seconds |
254
+ | `acceptedFormats` | `string[]` | `['video/mp4', 'video/webm', 'video/quicktime']` | Accepted MIME types |
255
+ | `extractFramesCount` | `number` | `3` | Number of frames to extract for moderation |
256
+ | `showPreview` | `boolean` | `true` | Show video preview with thumbnail |
257
+ | `blockUnsafe` | `boolean` | `true` | Prevent upload if content violates policy |
258
+ | `customPreview` | `(props) => ReactNode` | - | Custom preview component |
259
+ | `onModerationResult` | `(result) => void` | - | Called with full moderation response |
260
+ | `onModerationError` | `(error) => void` | - | Called on moderation errors |
261
+
262
+ #### Features
263
+
264
+ - **Drag and drop** - Drop videos directly onto the upload area
265
+ - **Thumbnail generation** - Automatically generates video thumbnail
266
+ - **Progress tracking** - Shows frame extraction and analysis progress
267
+ - **Duration validation** - Rejects videos exceeding maximum duration
268
+
269
+ ---
270
+
271
+ ## useModeration Hook
272
+
273
+ For custom components, use the `useModeration` hook directly:
62
274
 
63
275
  ```tsx
64
276
  import { useModeration } from '@vettly/react'
65
277
 
66
- function CustomInput() {
67
- const { check, result, isLoading } = useModeration({
68
- apiKey: 'sk_live_...',
69
- policy: 'community-safe'
278
+ function CustomModerationUI() {
279
+ const { result, check } = useModeration({
280
+ apiKey: 'pk_live_...',
281
+ policyId: 'community-safe',
282
+ debounceMs: 500,
283
+ enabled: true,
284
+ useFast: false,
285
+ onCheck: (response) => {
286
+ console.log(`Decision: ${response.decisionId}`)
287
+ },
288
+ onError: (error) => {
289
+ console.error('Check failed:', error)
290
+ }
70
291
  })
71
292
 
72
293
  return (
73
- <input onChange={(e) => check(e.target.value)} />
294
+ <div>
295
+ <textarea
296
+ onChange={(e) => check(e.target.value)}
297
+ style={{
298
+ borderColor: result.action === 'block' ? 'red' :
299
+ result.action === 'flag' ? 'yellow' :
300
+ result.action === 'warn' ? 'orange' : 'green'
301
+ }}
302
+ />
303
+
304
+ {result.isChecking && <span>Checking...</span>}
305
+
306
+ {result.error && <span className="error">{result.error}</span>}
307
+
308
+ {!result.isChecking && !result.error && (
309
+ <div>
310
+ <strong>Action:</strong> {result.action}
311
+ <ul>
312
+ {result.categories
313
+ .filter(c => c.triggered)
314
+ .map(c => (
315
+ <li key={c.category}>
316
+ {c.category}: {(c.score * 100).toFixed(0)}%
317
+ </li>
318
+ ))
319
+ }
320
+ </ul>
321
+ </div>
322
+ )}
323
+ </div>
74
324
  )
75
325
  }
76
326
  ```
77
327
 
78
- ## Pricing
328
+ ### Options
329
+
330
+ | Option | Type | Default | Description |
331
+ |--------|------|---------|-------------|
332
+ | `apiKey` | `string` | required | Your Vettly API key |
333
+ | `policyId` | `string` | required | Policy ID to apply |
334
+ | `debounceMs` | `number` | `500` | Milliseconds to debounce checks |
335
+ | `enabled` | `boolean` | `true` | Enable/disable moderation |
336
+ | `useFast` | `boolean` | `false` | Use fast endpoint for sub-100ms responses |
337
+ | `onCheck` | `(response) => void` | - | Called with full `CheckResponse` |
338
+ | `onError` | `(error) => void` | - | Called on errors |
339
+
340
+ ### Return Value
341
+
342
+ ```typescript
343
+ interface UseModerationReturn {
344
+ result: {
345
+ safe: boolean // True if content passes policy
346
+ flagged: boolean // True if content is flagged for review
347
+ action: 'allow' | 'warn' | 'flag' | 'block'
348
+ categories: Array<{
349
+ category: string
350
+ score: number // 0-1 confidence score
351
+ triggered: boolean // True if threshold exceeded
352
+ }>
353
+ isChecking: boolean // True while check is in progress
354
+ error: string | null // Error message if check failed
355
+ }
356
+ check: (content: string | CheckRequest) => Promise<void>
357
+ }
358
+ ```
359
+
360
+ ### Advanced Usage: Full Request Object
361
+
362
+ ```tsx
363
+ const { check } = useModeration({ apiKey, policyId })
364
+
365
+ // Check with full request object
366
+ check({
367
+ content: imageBase64,
368
+ contentType: 'image',
369
+ policyId: 'strict-images',
370
+ metadata: {
371
+ userId: 'user_123',
372
+ context: 'profile_photo'
373
+ }
374
+ })
375
+ ```
376
+
377
+ ---
378
+
379
+ ## Styling & Customization
380
+
381
+ ### Default Styles
382
+
383
+ Import the default stylesheet:
384
+
385
+ ```tsx
386
+ import '@vettly/react/styles.css'
387
+ ```
388
+
389
+ ### CSS Classes
390
+
391
+ All components use semantic CSS classes for easy customization:
392
+
393
+ ```css
394
+ /* Textarea wrapper */
395
+ .moderated-textarea-wrapper { }
396
+ .moderated-textarea { }
397
+
398
+ /* Feedback states */
399
+ .moderation-feedback { }
400
+ .feedback-checking { }
401
+ .feedback-safe { }
402
+ .feedback-warn { }
403
+ .feedback-flag { }
404
+ .feedback-block { }
405
+ .feedback-error { }
406
+
407
+ /* Image upload */
408
+ .moderated-image-upload { }
409
+ .upload-area { }
410
+ .upload-icon { }
411
+ .upload-text { }
412
+ .upload-hint { }
413
+ .upload-error { }
414
+ .image-preview { }
415
+ .preview-container { }
416
+ .preview-image { }
417
+ .preview-overlay { }
418
+ .preview-info { }
419
+ .moderation-status { }
420
+ .status-allow { }
421
+ .status-warn { }
422
+ .status-flag { }
423
+ .status-block { }
424
+ .preview-actions { }
425
+ .btn-remove { }
426
+ .btn-confirm { }
427
+
428
+ /* Video upload */
429
+ .moderated-video-upload { }
430
+ .drag-over { }
431
+ .thumbnail-container { }
432
+ .video-thumbnail { }
433
+ .play-overlay { }
434
+ .play-button { }
435
+ .duration-badge { }
436
+ .processing-overlay { }
437
+ .progress-bar { }
438
+ .progress-fill { }
439
+ ```
440
+
441
+ ### Border Colors by Action
442
+
443
+ The default styles use border colors to indicate moderation status:
444
+
445
+ | Action | Border Color |
446
+ |--------|--------------|
447
+ | Checking | Blue (`border-blue-300`) |
448
+ | Allow | Green (`border-green-400`) |
449
+ | Warn | Orange (`border-orange-400`) |
450
+ | Flag | Yellow (`border-yellow-400`) |
451
+ | Block | Red (`border-red-400`) |
452
+ | Error | Red (`border-red-400`) |
453
+
454
+ ### Custom Feedback Component
455
+
456
+ ```tsx
457
+ <ModeratedTextarea
458
+ apiKey={apiKey}
459
+ policyId={policyId}
460
+ showFeedback={false} // Disable default feedback
461
+ customFeedback={(result) => (
462
+ <div className="my-feedback">
463
+ {result.isChecking ? (
464
+ <Spinner />
465
+ ) : result.error ? (
466
+ <ErrorBanner message={result.error} />
467
+ ) : result.action === 'block' ? (
468
+ <BlockedMessage categories={result.categories} />
469
+ ) : (
470
+ <SafeIndicator />
471
+ )}
472
+ </div>
473
+ )}
474
+ />
475
+ ```
476
+
477
+ ---
478
+
479
+ ## Accessibility
480
+
481
+ All components follow accessibility best practices:
482
+
483
+ ### Keyboard Navigation
484
+
485
+ - **Tab** - Navigate between components
486
+ - **Enter/Space** - Activate upload buttons
487
+ - **Escape** - Cancel previews (when implemented)
488
+
489
+ ### ARIA Attributes
490
+
491
+ - Upload areas have `role="button"` and proper focus handling
492
+ - Error messages use appropriate ARIA live regions
493
+ - Progress indicators announce status changes
494
+
495
+ ### Screen Reader Support
496
+
497
+ - Feedback messages are announced as they change
498
+ - Upload areas have descriptive labels
499
+ - Error states provide clear explanations
500
+
501
+ ---
502
+
503
+ ## Server-Side Validation
504
+
505
+ Client-side moderation is for **user experience only**. Always validate on the server:
506
+
507
+ ```tsx
508
+ // Client-side (React)
509
+ function CommentForm() {
510
+ const [content, setContent] = useState('')
511
+ const [clientResult, setClientResult] = useState(null)
512
+
513
+ return (
514
+ <form onSubmit={async (e) => {
515
+ e.preventDefault()
516
+
517
+ // Server validates again - don't trust client-side result
518
+ const response = await fetch('/api/comments', {
519
+ method: 'POST',
520
+ body: JSON.stringify({
521
+ content,
522
+ clientDecisionId: clientResult?.decisionId // Optional: for correlation
523
+ })
524
+ })
525
+
526
+ if (response.ok) {
527
+ // Success
528
+ } else if (response.status === 403) {
529
+ // Server blocked content
530
+ const error = await response.json()
531
+ showError(error.message)
532
+ }
533
+ }}>
534
+ <ModeratedTextarea
535
+ apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY}
536
+ policyId="community-safe"
537
+ value={content}
538
+ onChange={setContent}
539
+ onModerationResult={setClientResult}
540
+ />
541
+ <button type="submit">Post</button>
542
+ </form>
543
+ )
544
+ }
545
+
546
+ // Server-side (API route)
547
+ import { createClient } from '@vettly/sdk'
548
+
549
+ const vettly = createClient(process.env.VETTLY_API_KEY)
550
+
551
+ export async function POST(req) {
552
+ const { content, clientDecisionId } = await req.json()
553
+
554
+ // Always validate server-side
555
+ const result = await vettly.check({
556
+ content,
557
+ policyId: 'community-safe',
558
+ metadata: { clientDecisionId } // Optional: for correlation
559
+ })
560
+
561
+ if (result.action === 'block') {
562
+ return Response.json(
563
+ { error: 'Content blocked', decisionId: result.decisionId },
564
+ { status: 403 }
565
+ )
566
+ }
567
+
568
+ // Store with decision ID for audit trail
569
+ await db.comments.create({
570
+ content,
571
+ moderationDecisionId: result.decisionId,
572
+ action: result.action
573
+ })
574
+
575
+ return Response.json({ success: true })
576
+ }
577
+ ```
79
578
 
80
- | Plan | Price | Text | Images | Videos |
81
- |------|-------|------|--------|--------|
82
- | Developer | Free | 2,000/mo | 100/mo | 25/mo |
83
- | Growth | $49/mo | 50,000/mo | 5,000/mo | 1,000/mo |
84
- | Pro | $149/mo | 250,000/mo | 25,000/mo | 5,000/mo |
85
- | Enterprise | Custom | Unlimited | Unlimited | Unlimited |
579
+ ---
86
580
 
87
581
  ## Links
88
582
 
89
583
  - [vettly.dev](https://vettly.dev) - Sign up
90
584
  - [docs.vettly.dev](https://docs.vettly.dev) - Documentation
585
+ - [@vettly/sdk](https://www.npmjs.com/package/@vettly/sdk) - Server-side SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vettly/react",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "React components for Vettly decision infrastructure. Content decisions with real-time feedback.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -41,7 +41,7 @@
41
41
  "license": "MIT",
42
42
  "repository": {
43
43
  "type": "git",
44
- "url": "https://github.com/brian-nextaura/vettly-docs.git",
44
+ "url": "https://github.com/nextauralabs/vettly-docs.git",
45
45
  "directory": "packages/react"
46
46
  },
47
47
  "homepage": "https://vettly.dev",
@@ -49,7 +49,7 @@
49
49
  "access": "public"
50
50
  },
51
51
  "bugs": {
52
- "url": "https://github.com/brian-nextaura/vettly-docs/issues"
52
+ "url": "https://github.com/nextauralabs/vettly-docs/issues"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "react": "^18.0.0",