howone 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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` |