howone 0.1.11 → 0.1.12
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 +1 -1
- package/templates/vite/.howone/skills/howone-sdk/SKILL.md +53 -63
- package/templates/vite/.howone/skills/howone-sdk/references/01-client-setup.md +278 -0
- package/templates/vite/.howone/skills/howone-sdk/references/02-entity-operations.md +379 -0
- package/templates/vite/.howone/skills/howone-sdk/references/03-ai-actions.md +489 -0
- package/templates/vite/.howone/skills/howone-sdk/references/04-auth.md +484 -0
- package/templates/vite/.howone/skills/howone-sdk/references/05-file-upload.md +319 -0
- package/templates/vite/.howone/skills/howone-sdk/references/06-react-integration.md +394 -0
- package/templates/vite/.howone/skills/howone-sdk/references/07-raw-http.md +299 -0
- package/templates/vite/.howone/skills/howone-sdk/references/08-manifest-codegen.md +400 -0
- package/templates/vite/.howone/skills/howone-sdk/references/usage-patterns.md +0 -215
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# File Upload
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`client.upload` provides three methods for uploading files to the HowOne storage backend:
|
|
6
|
+
- `upload.file(file, options?)` — general-purpose single file upload with progress and abort support
|
|
7
|
+
- `upload.image(file)` — convenience wrapper for image uploads
|
|
8
|
+
- `upload.batch(options)` — upload multiple files with concurrency control
|
|
9
|
+
|
|
10
|
+
All upload methods are accessed from the `client` (or `howone`) object directly — they are **not** part of entities or AI.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Types
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// ── Input ─────────────────────────────────────────────────────
|
|
18
|
+
type UploadableFile = File | Blob | string // string = URL or base64
|
|
19
|
+
|
|
20
|
+
// ── Options ───────────────────────────────────────────────────
|
|
21
|
+
type UploadOptions = {
|
|
22
|
+
onProgress?: (percent: number) => void // 0–100
|
|
23
|
+
signal?: AbortSignal // for cancellation
|
|
24
|
+
metadata?: Record<string, any> // custom metadata to attach
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Single upload result ──────────────────────────────────────
|
|
28
|
+
type UploadResponse = {
|
|
29
|
+
url: string // CDN URL of the uploaded file
|
|
30
|
+
thumbnailUrl?: string // thumbnail URL (for images/videos)
|
|
31
|
+
id?: string // storage file ID
|
|
32
|
+
size?: number // file size in bytes
|
|
33
|
+
mimeType?: string // detected MIME type
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Batch upload options ──────────────────────────────────────
|
|
37
|
+
type BatchUploadOptions = {
|
|
38
|
+
files: (File | Blob)[]
|
|
39
|
+
concurrent?: number // default: 3
|
|
40
|
+
onProgress?: (completed: number, total: number) => void
|
|
41
|
+
onFileComplete?: (result: UploadResponse | Error, index: number) => void
|
|
42
|
+
signal?: AbortSignal
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Batch upload result ───────────────────────────────────────
|
|
46
|
+
type BatchUploadResponse = {
|
|
47
|
+
success: UploadResponse[]
|
|
48
|
+
failed: Array<{ index: number; error: string }>
|
|
49
|
+
total: number
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## upload.file — Single File Upload
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import howone from '@/lib/sdk'
|
|
59
|
+
|
|
60
|
+
// Basic upload
|
|
61
|
+
const result = await howone.upload.file(file)
|
|
62
|
+
console.log(result.url) // 'https://cdn.howone.app/...'
|
|
63
|
+
console.log(result.size) // 12345 (bytes)
|
|
64
|
+
console.log(result.mimeType) // 'image/jpeg'
|
|
65
|
+
|
|
66
|
+
// With progress callback
|
|
67
|
+
const result = await howone.upload.file(file, {
|
|
68
|
+
onProgress: (percent) => {
|
|
69
|
+
console.log(`Upload progress: ${percent}%`)
|
|
70
|
+
setProgress(percent)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// With cancellation
|
|
75
|
+
const controller = new AbortController()
|
|
76
|
+
const promise = howone.upload.file(file, {
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
onProgress: setProgress,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Cancel after 5 seconds
|
|
82
|
+
setTimeout(() => controller.abort(), 5000)
|
|
83
|
+
|
|
84
|
+
const result = await promise
|
|
85
|
+
|
|
86
|
+
// With metadata
|
|
87
|
+
const result = await howone.upload.file(file, {
|
|
88
|
+
metadata: {
|
|
89
|
+
entityId: story.id,
|
|
90
|
+
uploadedBy: user.id,
|
|
91
|
+
category: 'cover',
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## upload.image — Image Shorthand
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import howone from '@/lib/sdk'
|
|
102
|
+
|
|
103
|
+
// Accepts File, Blob, or a URL/base64 string
|
|
104
|
+
const { url } = await howone.upload.image(imageFile)
|
|
105
|
+
console.log(url) // 'https://cdn.howone.app/images/...'
|
|
106
|
+
|
|
107
|
+
// Use the URL directly in an img tag or save to an entity
|
|
108
|
+
await howone.entities.Story.update(storyId, { coverUrl: url })
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## upload.batch — Multiple Files
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import howone from '@/lib/sdk'
|
|
117
|
+
|
|
118
|
+
const files: File[] = Array.from(fileInput.files ?? [])
|
|
119
|
+
|
|
120
|
+
const result = await howone.upload.batch({
|
|
121
|
+
files,
|
|
122
|
+
concurrent: 3, // upload 3 at a time
|
|
123
|
+
|
|
124
|
+
onProgress: (completed, total) => {
|
|
125
|
+
console.log(`${completed} / ${total} files uploaded`)
|
|
126
|
+
setProgress(Math.round((completed / total) * 100))
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
onFileComplete: (result, index) => {
|
|
130
|
+
if (result instanceof Error) {
|
|
131
|
+
console.error(`File ${index} failed:`, result.message)
|
|
132
|
+
} else {
|
|
133
|
+
console.log(`File ${index} URL:`, result.url)
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
console.log('Uploaded:', result.success.length)
|
|
139
|
+
console.log('Failed:', result.failed.length)
|
|
140
|
+
|
|
141
|
+
// Collect all URLs
|
|
142
|
+
const urls = result.success.map(r => r.url)
|
|
143
|
+
|
|
144
|
+
// With cancellation
|
|
145
|
+
const controller = new AbortController()
|
|
146
|
+
const resultPromise = howone.upload.batch({
|
|
147
|
+
files,
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
onProgress: (c, t) => console.log(c, '/', t),
|
|
150
|
+
})
|
|
151
|
+
// controller.abort() to cancel
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## React Patterns
|
|
157
|
+
|
|
158
|
+
### Single image upload component
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
import { useRef, useState } from 'react'
|
|
162
|
+
import howone from '@/lib/sdk'
|
|
163
|
+
|
|
164
|
+
export function ImageUploader({
|
|
165
|
+
onUpload,
|
|
166
|
+
}: {
|
|
167
|
+
onUpload: (url: string) => void
|
|
168
|
+
}) {
|
|
169
|
+
const [uploading, setUploading] = useState(false)
|
|
170
|
+
const [progress, setProgress] = useState(0)
|
|
171
|
+
const [error, setError] = useState<string | null>(null)
|
|
172
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
173
|
+
|
|
174
|
+
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
175
|
+
const file = e.target.files?.[0]
|
|
176
|
+
if (!file) return
|
|
177
|
+
|
|
178
|
+
setUploading(true)
|
|
179
|
+
setProgress(0)
|
|
180
|
+
setError(null)
|
|
181
|
+
abortRef.current = new AbortController()
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const result = await howone.upload.file(file, {
|
|
185
|
+
signal: abortRef.current.signal,
|
|
186
|
+
onProgress: setProgress,
|
|
187
|
+
})
|
|
188
|
+
onUpload(result.url)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if ((err as Error).name !== 'AbortError') {
|
|
191
|
+
setError(err instanceof Error ? err.message : 'Upload failed')
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
setUploading(false)
|
|
195
|
+
abortRef.current = null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function handleCancel() {
|
|
200
|
+
abortRef.current?.abort()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div>
|
|
205
|
+
<input
|
|
206
|
+
type="file"
|
|
207
|
+
accept="image/*"
|
|
208
|
+
onChange={handleChange}
|
|
209
|
+
disabled={uploading}
|
|
210
|
+
/>
|
|
211
|
+
{uploading && (
|
|
212
|
+
<div>
|
|
213
|
+
<progress value={progress} max={100} />
|
|
214
|
+
<span>{progress}%</span>
|
|
215
|
+
<button onClick={handleCancel}>Cancel</button>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
{error && <p className="error">{error}</p>}
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Multi-file upload with gallery preview
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { useState } from 'react'
|
|
228
|
+
import howone from '@/lib/sdk'
|
|
229
|
+
|
|
230
|
+
export function MultiFileUploader() {
|
|
231
|
+
const [files, setFiles] = useState<File[]>([])
|
|
232
|
+
const [uploading, setUploading] = useState(false)
|
|
233
|
+
const [progress, setProgress] = useState({ completed: 0, total: 0 })
|
|
234
|
+
const [uploadedUrls, setUploadedUrls] = useState<string[]>([])
|
|
235
|
+
const [failedCount, setFailedCount] = useState(0)
|
|
236
|
+
|
|
237
|
+
function handleSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
|
238
|
+
setFiles(Array.from(e.target.files ?? []))
|
|
239
|
+
setUploadedUrls([])
|
|
240
|
+
setFailedCount(0)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function handleUpload() {
|
|
244
|
+
if (!files.length) return
|
|
245
|
+
setUploading(true)
|
|
246
|
+
setProgress({ completed: 0, total: files.length })
|
|
247
|
+
|
|
248
|
+
const result = await howone.upload.batch({
|
|
249
|
+
files,
|
|
250
|
+
concurrent: 3,
|
|
251
|
+
onProgress: (completed, total) => setProgress({ completed, total }),
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
setUploadedUrls(result.success.map(r => r.url))
|
|
255
|
+
setFailedCount(result.failed.length)
|
|
256
|
+
setUploading(false)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div>
|
|
261
|
+
<input type="file" multiple onChange={handleSelect} disabled={uploading} />
|
|
262
|
+
<button onClick={handleUpload} disabled={uploading || !files.length}>
|
|
263
|
+
{uploading
|
|
264
|
+
? `Uploading ${progress.completed}/${progress.total}...`
|
|
265
|
+
: `Upload ${files.length} files`}
|
|
266
|
+
</button>
|
|
267
|
+
{failedCount > 0 && <p>{failedCount} files failed to upload</p>}
|
|
268
|
+
<div className="gallery">
|
|
269
|
+
{uploadedUrls.map((url, i) => (
|
|
270
|
+
<img key={i} src={url} alt={`Upload ${i}`} />
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Upload and save to entity
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
import howone, { type StoryUpdate } from '@/lib/sdk'
|
|
282
|
+
|
|
283
|
+
async function uploadCoverAndUpdate(storyId: string, coverFile: File) {
|
|
284
|
+
// 1. Upload image
|
|
285
|
+
const { url } = await howone.upload.image(coverFile)
|
|
286
|
+
|
|
287
|
+
// 2. Update entity with the uploaded URL
|
|
288
|
+
const updated = await howone.entities.Story.update(storyId, {
|
|
289
|
+
coverUrl: url,
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
return updated
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Upload from AI output (AI-generated image URL → storage)
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
import howone from '@/lib/sdk'
|
|
300
|
+
|
|
301
|
+
async function saveGeneratedImage(aiImageUrl: string, storyId: string) {
|
|
302
|
+
// Upload by URL (download + re-upload to HowOne storage)
|
|
303
|
+
const { url } = await howone.upload.image(aiImageUrl)
|
|
304
|
+
|
|
305
|
+
await howone.entities.Story.update(storyId, { coverUrl: url })
|
|
306
|
+
return url
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Common Mistakes
|
|
313
|
+
|
|
314
|
+
| Mistake | Correct Pattern |
|
|
315
|
+
|---|---|
|
|
316
|
+
| Assuming `upload.image` returns the same shape as `upload.file` | `upload.image` returns `{ url: string }` only; `upload.file` returns full `UploadResponse` |
|
|
317
|
+
| Not handling partial batch failures | Always check `result.failed.length` and `result.success` separately |
|
|
318
|
+
| Leaking upload after component unmount | Store `AbortController` in a ref and abort on cleanup |
|
|
319
|
+
| Saving a raw blob URL (`blob://...`) to an entity | Always await the upload first, then save the returned CDN `url` |
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# React Integration
|
|
2
|
+
|
|
3
|
+
## What `@howone/sdk/react` Provides
|
|
4
|
+
|
|
5
|
+
`@howone/sdk/react` is a **thin UI layer** for auth state, theme, and loading spinners. It does **not** provide entity, query, or AI data hooks.
|
|
6
|
+
|
|
7
|
+
Exports:
|
|
8
|
+
- `HowOneProvider` — all-in-one app provider (auth, theme, toasts, floating button)
|
|
9
|
+
- `useHowoneContext` — access auth state (user, token, isAuthenticated, logout)
|
|
10
|
+
- `FloatingButton` — a floating action button (shows Login when unauthenticated)
|
|
11
|
+
- `Loading` — full-page or inline loading component
|
|
12
|
+
- `LoadingSpinner` — headless spinner for composition
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Import
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import {
|
|
20
|
+
HowOneProvider,
|
|
21
|
+
useHowoneContext,
|
|
22
|
+
FloatingButton,
|
|
23
|
+
Loading,
|
|
24
|
+
LoadingSpinner,
|
|
25
|
+
} from '@howone/sdk/react'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## HowOneProvider
|
|
31
|
+
|
|
32
|
+
Wrap your entire app. Provides auth, theme, and toast context to all children.
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
// main.tsx or app/layout.tsx
|
|
36
|
+
import React from 'react'
|
|
37
|
+
import ReactDOM from 'react-dom/client'
|
|
38
|
+
import { HowOneProvider } from '@howone/sdk/react'
|
|
39
|
+
import App from './App'
|
|
40
|
+
|
|
41
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
42
|
+
<React.StrictMode>
|
|
43
|
+
<HowOneProvider
|
|
44
|
+
projectId={import.meta.env.VITE_HOWONE_PROJECT_ID}
|
|
45
|
+
auth="optional"
|
|
46
|
+
brand="visible"
|
|
47
|
+
theme="system"
|
|
48
|
+
>
|
|
49
|
+
<App />
|
|
50
|
+
</HowOneProvider>
|
|
51
|
+
</React.StrictMode>
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### HowOneProviderProps
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
type HowOneAuthMode = 'required' | 'optional' | 'none'
|
|
59
|
+
type HowOneThemeMode = 'dark' | 'light' | 'system' | 'inherit'
|
|
60
|
+
type HowOneBrandMode = 'visible' | 'hidden'
|
|
61
|
+
|
|
62
|
+
interface HowOneProviderProps {
|
|
63
|
+
children: React.ReactNode
|
|
64
|
+
|
|
65
|
+
// Project configuration (optional if createClient already set it)
|
|
66
|
+
projectId?: string
|
|
67
|
+
|
|
68
|
+
// Auth behaviour
|
|
69
|
+
auth?: HowOneAuthMode
|
|
70
|
+
// 'required' — redirects unauthenticated users to login page
|
|
71
|
+
// 'optional' — allows both authenticated and unauthenticated users
|
|
72
|
+
// 'none' — disables auth entirely
|
|
73
|
+
|
|
74
|
+
// Brand button (HowOne branding)
|
|
75
|
+
brand?: HowOneBrandMode // default: 'visible'
|
|
76
|
+
showBrandButton?: boolean // alias for brand
|
|
77
|
+
|
|
78
|
+
// Theme
|
|
79
|
+
theme?: HowOneThemeMode // default: 'system'
|
|
80
|
+
themeStorageKey?: string // localStorage key for theme preference
|
|
81
|
+
forceTheme?: boolean // ignore user preference and force theme
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Common provider configurations
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
// Public app — no auth required
|
|
89
|
+
<HowOneProvider auth="none" theme="system" brand="hidden">
|
|
90
|
+
<App />
|
|
91
|
+
</HowOneProvider>
|
|
92
|
+
|
|
93
|
+
// Auth-gated app — redirect to login if not authenticated
|
|
94
|
+
<HowOneProvider auth="required" theme="system">
|
|
95
|
+
<App />
|
|
96
|
+
</HowOneProvider>
|
|
97
|
+
|
|
98
|
+
// Optional auth — user can browse without logging in
|
|
99
|
+
<HowOneProvider auth="optional" theme="inherit" brand="visible">
|
|
100
|
+
<App />
|
|
101
|
+
</HowOneProvider>
|
|
102
|
+
|
|
103
|
+
// Dark mode forced
|
|
104
|
+
<HowOneProvider auth="optional" theme="dark" forceTheme>
|
|
105
|
+
<App />
|
|
106
|
+
</HowOneProvider>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## useHowoneContext
|
|
112
|
+
|
|
113
|
+
Access auth state inside any component wrapped by `HowOneProvider`.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
type HowoneContextValue = {
|
|
117
|
+
user: {
|
|
118
|
+
id: string
|
|
119
|
+
email: string
|
|
120
|
+
name: string
|
|
121
|
+
avatar: string
|
|
122
|
+
} | null
|
|
123
|
+
token: string | null
|
|
124
|
+
isAuthenticated: boolean
|
|
125
|
+
logout: () => void
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Usage
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
import { useHowoneContext } from '@howone/sdk/react'
|
|
133
|
+
|
|
134
|
+
function UserMenu() {
|
|
135
|
+
const { user, isAuthenticated, logout } = useHowoneContext()
|
|
136
|
+
|
|
137
|
+
if (!isAuthenticated || !user) {
|
|
138
|
+
return <a href="/login">Login</a>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div>
|
|
143
|
+
<img src={user.avatar} alt={user.name} />
|
|
144
|
+
<span>{user.name}</span>
|
|
145
|
+
<button onClick={logout}>Logout</button>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Conditional rendering based on auth state
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
function Dashboard() {
|
|
155
|
+
const { isAuthenticated, user } = useHowoneContext()
|
|
156
|
+
|
|
157
|
+
if (!isAuthenticated) {
|
|
158
|
+
return <div>Please log in to continue.</div>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return <div>Welcome, {user?.name}!</div>
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Using token for direct API calls
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
import { useHowoneContext } from '@howone/sdk/react'
|
|
169
|
+
|
|
170
|
+
function DebugPanel() {
|
|
171
|
+
const { token } = useHowoneContext()
|
|
172
|
+
|
|
173
|
+
async function copyToken() {
|
|
174
|
+
if (token) await navigator.clipboard.writeText(token)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<button onClick={copyToken} disabled={!token}>
|
|
179
|
+
Copy JWT Token
|
|
180
|
+
</button>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## FloatingButton
|
|
188
|
+
|
|
189
|
+
A floating action button that renders in the bottom corner of the screen. When unauthenticated, it shows a "Login" label by default.
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
import { FloatingButton } from '@howone/sdk/react'
|
|
193
|
+
|
|
194
|
+
// Default — shows Login/brand button
|
|
195
|
+
<FloatingButton />
|
|
196
|
+
|
|
197
|
+
// Custom text and handler
|
|
198
|
+
<FloatingButton
|
|
199
|
+
text="Get Started"
|
|
200
|
+
onClick={() => router.push('/signup')}
|
|
201
|
+
/>
|
|
202
|
+
|
|
203
|
+
// Custom styling
|
|
204
|
+
<FloatingButton
|
|
205
|
+
text="Support"
|
|
206
|
+
onClick={() => setShowChat(true)}
|
|
207
|
+
className="bg-blue-600 hover:bg-blue-700"
|
|
208
|
+
/>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### FloatingButtonProps
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
interface FloatingButtonProps {
|
|
215
|
+
text?: string // button label (default: 'Login' or brand text)
|
|
216
|
+
onClick?: () => void
|
|
217
|
+
className?: string // additional CSS classes
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Loading Components
|
|
224
|
+
|
|
225
|
+
### Loading — full-page or inline loading state
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
import { Loading } from '@howone/sdk/react'
|
|
229
|
+
|
|
230
|
+
// Full-screen overlay
|
|
231
|
+
<Loading fullScreen label="Loading your workspace..." />
|
|
232
|
+
|
|
233
|
+
// Inline with description
|
|
234
|
+
<Loading
|
|
235
|
+
label="Generating story..."
|
|
236
|
+
description="This may take a moment"
|
|
237
|
+
size="lg"
|
|
238
|
+
tone="brand"
|
|
239
|
+
/>
|
|
240
|
+
|
|
241
|
+
// Minimal
|
|
242
|
+
<Loading />
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### LoadingSpinner — headless spinner
|
|
246
|
+
|
|
247
|
+
```tsx
|
|
248
|
+
import { LoadingSpinner } from '@howone/sdk/react'
|
|
249
|
+
|
|
250
|
+
// Sizes: 'sm' | 'md' | 'lg'
|
|
251
|
+
// Tones: 'brand' | 'neutral' | 'inverse'
|
|
252
|
+
|
|
253
|
+
<LoadingSpinner size="md" tone="brand" />
|
|
254
|
+
<LoadingSpinner size="sm" tone="neutral" className="mr-2" />
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### LoadingProps
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
type LoadingSize = 'sm' | 'md' | 'lg'
|
|
261
|
+
type LoadingTone = 'brand' | 'neutral' | 'inverse'
|
|
262
|
+
|
|
263
|
+
interface LoadingProps {
|
|
264
|
+
size?: LoadingSize // default: 'md'
|
|
265
|
+
tone?: LoadingTone // default: 'brand'
|
|
266
|
+
label?: string // primary text
|
|
267
|
+
description?: string // secondary text
|
|
268
|
+
className?: string
|
|
269
|
+
fullScreen?: boolean // renders over full viewport
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface LoadingSpinnerProps {
|
|
273
|
+
size?: LoadingSize
|
|
274
|
+
tone?: LoadingTone
|
|
275
|
+
className?: string
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Entity and AI Data in React
|
|
282
|
+
|
|
283
|
+
`@howone/sdk/react` provides **no data hooks**. Use `useEffect`/`useState` or TanStack Query around the plain async SDK calls.
|
|
284
|
+
|
|
285
|
+
### Pattern: auth-gated data fetch
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
import { useEffect, useState } from 'react'
|
|
289
|
+
import { useHowoneContext } from '@howone/sdk/react'
|
|
290
|
+
import { Loading } from '@howone/sdk/react'
|
|
291
|
+
import howone, { type StoryRecord } from '@/lib/sdk'
|
|
292
|
+
|
|
293
|
+
function MyStories() {
|
|
294
|
+
const { isAuthenticated } = useHowoneContext()
|
|
295
|
+
const [stories, setStories] = useState<StoryRecord[]>([])
|
|
296
|
+
const [loading, setLoading] = useState(false)
|
|
297
|
+
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (!isAuthenticated) return
|
|
300
|
+
|
|
301
|
+
setLoading(true)
|
|
302
|
+
howone.entities.Story.query.mine({ page: { number: 1, size: 20 } })
|
|
303
|
+
.then(result => setStories(result.items))
|
|
304
|
+
.finally(() => setLoading(false))
|
|
305
|
+
}, [isAuthenticated])
|
|
306
|
+
|
|
307
|
+
if (!isAuthenticated) return <div>Please log in.</div>
|
|
308
|
+
if (loading) return <Loading label="Loading your stories..." />
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<ul>
|
|
312
|
+
{stories.map(s => <li key={s.id}>{s.title}</li>)}
|
|
313
|
+
</ul>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Pattern: Full app layout
|
|
319
|
+
|
|
320
|
+
```tsx
|
|
321
|
+
// src/main.tsx
|
|
322
|
+
import React from 'react'
|
|
323
|
+
import ReactDOM from 'react-dom/client'
|
|
324
|
+
import { HowOneProvider } from '@howone/sdk/react'
|
|
325
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
326
|
+
import App from './App'
|
|
327
|
+
|
|
328
|
+
const queryClient = new QueryClient()
|
|
329
|
+
|
|
330
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
331
|
+
<React.StrictMode>
|
|
332
|
+
<HowOneProvider
|
|
333
|
+
projectId={import.meta.env.VITE_HOWONE_PROJECT_ID}
|
|
334
|
+
auth="optional"
|
|
335
|
+
theme="system"
|
|
336
|
+
brand="visible"
|
|
337
|
+
>
|
|
338
|
+
<QueryClientProvider client={queryClient}>
|
|
339
|
+
<App />
|
|
340
|
+
</QueryClientProvider>
|
|
341
|
+
</HowOneProvider>
|
|
342
|
+
</React.StrictMode>
|
|
343
|
+
)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Pattern: conditional AI action based on auth
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
import { useState } from 'react'
|
|
350
|
+
import { useHowoneContext } from '@howone/sdk/react'
|
|
351
|
+
import howone from '@/lib/sdk'
|
|
352
|
+
|
|
353
|
+
function AIGenerateButton({ topic }: { topic: string }) {
|
|
354
|
+
const { isAuthenticated } = useHowoneContext()
|
|
355
|
+
const [loading, setLoading] = useState(false)
|
|
356
|
+
const [result, setResult] = useState<string | null>(null)
|
|
357
|
+
|
|
358
|
+
async function handleGenerate() {
|
|
359
|
+
if (!isAuthenticated) {
|
|
360
|
+
howone.auth.login()
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
setLoading(true)
|
|
364
|
+
try {
|
|
365
|
+
const res = await howone.ai.generateStory.run({ topic, language: 'en' })
|
|
366
|
+
if (res.success && res.finalResult) {
|
|
367
|
+
setResult((res.finalResult as any).content)
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
setLoading(false)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div>
|
|
376
|
+
<button onClick={handleGenerate} disabled={loading}>
|
|
377
|
+
{loading ? 'Generating...' : isAuthenticated ? 'Generate' : 'Login to Generate'}
|
|
378
|
+
</button>
|
|
379
|
+
{result && <p>{result}</p>}
|
|
380
|
+
</div>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Common Mistakes
|
|
388
|
+
|
|
389
|
+
| Mistake | Correct Pattern |
|
|
390
|
+
|---|---|
|
|
391
|
+
| `import { useHowoneContext } from '@howone/sdk'` | Import from `'@howone/sdk/react'` |
|
|
392
|
+
| Expecting entity data hooks from `useHowoneContext` | Use `useEffect`/`useState` + `howone.entities.*` directly |
|
|
393
|
+
| Placing `<HowOneProvider>` inside a component that re-renders | Place at root level (main.tsx / app layout) |
|
|
394
|
+
| Using `user.id` without null-checking `user` | Guard: `if (!user) return` or use optional chaining `user?.id` |
|