@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.
- package/README.md +519 -24
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# @vettly/react
|
|
2
2
|
|
|
3
|
-
React components
|
|
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
|
-
|
|
39
|
+
policyId="community-safe"
|
|
22
40
|
placeholder="Write a comment..."
|
|
23
41
|
onModerationResult={(result) => {
|
|
24
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
const {
|
|
68
|
-
apiKey: '
|
|
69
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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/
|
|
52
|
+
"url": "https://github.com/nextauralabs/vettly-docs/issues"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"react": "^18.0.0",
|