@prmichaelsen/acp-visualizer 0.1.0
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 +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# Firebase Storage
|
|
2
|
+
|
|
3
|
+
**Category**: Code
|
|
4
|
+
**Applicable To**: All file upload/download, signed URL generation, image proxy, content moderation, and storage cleanup
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
This pattern covers how Firebase Storage is used via `@prmichaelsen/firebase-admin-sdk-v8` for file operations: chunked WebSocket uploads through a Durable Object, signed URL generation, server-side image proxy with ACL, content moderation via Google Cloud Vision, and storage cleanup on account deletion. The upload pipeline streams files from client → UploadManager DO → Firebase Storage with two-phase progress reporting.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## When to Use This Pattern
|
|
16
|
+
|
|
17
|
+
**Use this pattern when:**
|
|
18
|
+
- Adding a new file upload flow (new media type or storage path)
|
|
19
|
+
- Generating signed URLs for stored files
|
|
20
|
+
- Building an endpoint that serves or proxies stored files
|
|
21
|
+
- Adding content moderation to a new upload type
|
|
22
|
+
- Implementing storage cleanup for a new entity type
|
|
23
|
+
|
|
24
|
+
**Don't use this pattern when:**
|
|
25
|
+
- Storing structured data (use Firestore — see `tanstack-cloudflare.firebase-firestore`)
|
|
26
|
+
- Working with client-only file handling (canvas, blob URLs)
|
|
27
|
+
- Building external storage integrations (S3, R2, etc.)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Core Principles
|
|
32
|
+
|
|
33
|
+
1. **WebSocket Streaming**: Uploads use chunked WebSocket messages through a Durable Object, bypassing request size limits
|
|
34
|
+
2. **Deny-All-Read Storage Rules**: Firebase Storage rules deny all direct reads — the image proxy (`/api/storage/image`) is the only access path
|
|
35
|
+
3. **Fail-Open Moderation**: If Google Vision API is down, uploads proceed — best-effort safety, never block on transient failures
|
|
36
|
+
4. **Metadata-Driven ACL**: File metadata (userId, conversationId) stored in Firebase is the source of truth for access control, not URL parameters
|
|
37
|
+
5. **Two-Phase Progress**: 0–50% = client-to-DO transfer, 50–100% = DO-to-Firebase upload
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Implementation
|
|
42
|
+
|
|
43
|
+
### Storage Path Structure
|
|
44
|
+
|
|
45
|
+
**File**: `src/constant/collections.ts`
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
export const STORAGE_BASE = BASE.replace(/\./g, '_')
|
|
49
|
+
// Paths: {STORAGE_BASE}/users/{userId}/{path}/{mediaId}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| Media Type | Path | Example |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Chat images | `['chat']` | `agentbase/users/abc/chat/media123` |
|
|
55
|
+
| Profile avatars | `['profile', 'avatar']` | `agentbase/users/abc/profile/avatar/media123` |
|
|
56
|
+
| Profile banners | `['profile', 'banner']` | `agentbase/users/abc/profile/banner/media123` |
|
|
57
|
+
| Widget images | `['profile', 'widgets']` | `agentbase/users/abc/profile/widgets/media123` |
|
|
58
|
+
|
|
59
|
+
### Upload Flow
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Client (upload-client.ts) UploadManager DO Firebase Storage
|
|
63
|
+
│ │ │
|
|
64
|
+
├─ WebSocket connect ──────────► │ │
|
|
65
|
+
├─ init { userId, path, size } ──► │ │
|
|
66
|
+
│ ◄─── ready ────────────────── │ │
|
|
67
|
+
├─ chunk (256KB base64) ────────► │ (buffers in memory) │
|
|
68
|
+
├─ chunk ────────────────────────► │ │
|
|
69
|
+
├─ chunk (final) ────────────────► │ │
|
|
70
|
+
│ (0-50% progress) │ │
|
|
71
|
+
│ ├─ Content moderation ────► │
|
|
72
|
+
│ ◄─── moderating ───────────── │ (Vision SafeSearch) │
|
|
73
|
+
│ ◄─── moderation_result ────── │ │
|
|
74
|
+
│ ├─ uploadFileResumable() ────► │
|
|
75
|
+
│ ◄─── firebase_progress ────── │ (1MB chunks, 50-100%) │
|
|
76
|
+
│ │ ◄───── upload complete ──── │
|
|
77
|
+
│ ├─ generateSignedUrl() ──────► │
|
|
78
|
+
│ ◄─── success { signedUrl } ─── │ ◄───── signed URL ──────── │
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Key SDK Functions
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import {
|
|
85
|
+
uploadFileResumable, // Upload with progress tracking
|
|
86
|
+
generateSignedUrl, // Time-limited read URLs
|
|
87
|
+
downloadFile, // Download file buffer
|
|
88
|
+
getFileMetadata, // File metadata (content-type, custom metadata)
|
|
89
|
+
listFiles, // Paginated file listing
|
|
90
|
+
deleteFile, // Delete single file
|
|
91
|
+
} from '@prmichaelsen/firebase-admin-sdk-v8'
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### UploadManager Durable Object
|
|
95
|
+
|
|
96
|
+
**File**: `src/durable-objects/UploadManager.ts`
|
|
97
|
+
|
|
98
|
+
Upload to Firebase with progress:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
await uploadFileResumable(storagePath, completeBuffer, contentType, {
|
|
102
|
+
chunkSize: 1024 * 1024, // 1MB chunks to Firebase
|
|
103
|
+
onProgress: (uploaded, total) => {
|
|
104
|
+
const pct = 50 + Math.round((uploaded / total) * 50) // 50-100% phase
|
|
105
|
+
ws.send(JSON.stringify({ type: 'firebase_progress', progress: pct }))
|
|
106
|
+
},
|
|
107
|
+
metadata: {
|
|
108
|
+
userId,
|
|
109
|
+
path: path.join('/'),
|
|
110
|
+
uploadedAt: new Date().toISOString(),
|
|
111
|
+
...customMetadata,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Generate signed URL after upload:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1000)
|
|
120
|
+
const signedUrl = await generateSignedUrl(storagePath, {
|
|
121
|
+
action: 'read',
|
|
122
|
+
expires: expiresAt,
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Content Moderation
|
|
127
|
+
|
|
128
|
+
**File**: `src/constant/moderation.ts`
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
export const MODERATION_CONFIG = {
|
|
132
|
+
adult: {
|
|
133
|
+
reject: ['VERY_LIKELY'] as const,
|
|
134
|
+
warn: ['LIKELY', 'POSSIBLE'] as const,
|
|
135
|
+
},
|
|
136
|
+
violence: {
|
|
137
|
+
reject: ['VERY_LIKELY'] as const,
|
|
138
|
+
warn: ['LIKELY', 'POSSIBLE'] as const,
|
|
139
|
+
},
|
|
140
|
+
racy: {
|
|
141
|
+
reject: [] as const, // Never reject on racy alone
|
|
142
|
+
warn: [] as const,
|
|
143
|
+
},
|
|
144
|
+
failOpen: true, // Allow upload if Vision API fails
|
|
145
|
+
skipContentTypes: ['image/svg+xml'],
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Moderation is skipped for: video files, SVG images, and `skipModeration: true` uploads (e.g., profile avatars use a separate moderation flow).
|
|
150
|
+
|
|
151
|
+
### Image Proxy (ACL Enforcement)
|
|
152
|
+
|
|
153
|
+
**File**: `src/routes/api/storage/image.tsx`
|
|
154
|
+
|
|
155
|
+
Access tiers (checked in order):
|
|
156
|
+
1. **Owner**: Requesting user === file owner → allow
|
|
157
|
+
2. **Profile images**: Path contains `/profile/` → allow (public)
|
|
158
|
+
3. **Space content**: Path contains `/spaces/` → allow (public)
|
|
159
|
+
4. **Chat DM**: Query conversation, validate participant membership
|
|
160
|
+
5. **Chat Group**: Query conversation, validate `can_read` permission
|
|
161
|
+
6. **Deny**: All others → 403
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// Download and serve the file
|
|
165
|
+
const buffer = await downloadFile(storagePath)
|
|
166
|
+
const metadata = await getFileMetadata(storagePath)
|
|
167
|
+
|
|
168
|
+
return new Response(buffer, {
|
|
169
|
+
headers: {
|
|
170
|
+
'Content-Type': metadata.contentType || 'application/octet-stream',
|
|
171
|
+
'Cache-Control': isPublic ? 'public, max-age=3300' : 'private, max-age=3300',
|
|
172
|
+
'X-Crop-X': cropData?.x?.toString() ?? '',
|
|
173
|
+
// ... other crop metadata headers
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### MediaStorageService (Domain Wrapper)
|
|
179
|
+
|
|
180
|
+
**File**: `src/services/media-storage.service.ts`
|
|
181
|
+
|
|
182
|
+
High-level methods that set path and metadata for each use case:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
static async saveChatImage(file, userId, conversationId, messageId, callbacks) {
|
|
186
|
+
return uploadToStorage(file, userId, {
|
|
187
|
+
path: ['chat'],
|
|
188
|
+
metadata: { conversationId, messageId, mediaType: 'image' },
|
|
189
|
+
...callbacks,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
static async saveProfileAvatar(file, userId, callbacks) {
|
|
194
|
+
return uploadToStorage(file, userId, {
|
|
195
|
+
path: ['profile', 'avatar'],
|
|
196
|
+
metadata: { mediaType: 'avatar' },
|
|
197
|
+
skipModeration: true, // Uses separate moderation flow
|
|
198
|
+
...callbacks,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Examples
|
|
206
|
+
|
|
207
|
+
### Example 1: Downloading a File for AI Processing
|
|
208
|
+
|
|
209
|
+
**File**: `src/lib/chat/message-formatter.ts`
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { downloadFile } from '@prmichaelsen/firebase-admin-sdk-v8'
|
|
213
|
+
|
|
214
|
+
const buffer = await downloadFile(storagePath)
|
|
215
|
+
let text = new TextDecoder('utf-8').decode(buffer)
|
|
216
|
+
const MAX_FILE_BYTES = 50 * 1024
|
|
217
|
+
if (text.length > MAX_FILE_BYTES) {
|
|
218
|
+
text = text.slice(0, MAX_FILE_BYTES) + '...[truncated]'
|
|
219
|
+
}
|
|
220
|
+
content.push({ type: 'text', text: `[File: ${fileName}]\n${text}\n[End File]` })
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Example 2: Bulk Storage Cleanup on Account Deletion
|
|
224
|
+
|
|
225
|
+
**File**: `src/services/account-deletion.service.ts`
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
import { listFiles, deleteFile } from '@prmichaelsen/firebase-admin-sdk-v8'
|
|
229
|
+
|
|
230
|
+
const prefix = `${STORAGE_BASE}/users/${userId}/`
|
|
231
|
+
let pageToken: string | undefined
|
|
232
|
+
|
|
233
|
+
do {
|
|
234
|
+
const listResult = await listFiles({
|
|
235
|
+
prefix,
|
|
236
|
+
maxResults: 500,
|
|
237
|
+
...(pageToken ? { pageToken } : {}),
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
for (const file of listResult.files) {
|
|
241
|
+
await deleteFile(file.name)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pageToken = listResult.nextPageToken
|
|
245
|
+
} while (pageToken)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Example 3: Crop Metadata Storage
|
|
249
|
+
|
|
250
|
+
**File**: `src/services/media-crop-database.service.ts`
|
|
251
|
+
|
|
252
|
+
Crop coordinates stored in Firestore at `{BASE}.users/{userId}/media-crops/{mediaId}`:
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
static async setCrop(userId: string, mediaId: string, storagePath: string, crop: CropData) {
|
|
256
|
+
const collection = getUserMediaCropsCollection(userId)
|
|
257
|
+
await setDocument(collection, mediaId, {
|
|
258
|
+
media_id: mediaId,
|
|
259
|
+
storage_path: storagePath,
|
|
260
|
+
crop,
|
|
261
|
+
created_at: new Date().toISOString(),
|
|
262
|
+
updated_at: new Date().toISOString(),
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Crop metadata returned as response headers from the image proxy:
|
|
268
|
+
`X-Crop-X`, `X-Crop-Y`, `X-Crop-Width`, `X-Crop-Height`, `X-Image-Width`, `X-Image-Height`
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Anti-Patterns
|
|
273
|
+
|
|
274
|
+
### Serving Files Directly from Signed URLs
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
// Bad: Bypasses ACL — anyone with the URL can access
|
|
278
|
+
const url = await generateSignedUrl(path, { action: 'read', expires })
|
|
279
|
+
return new Response(JSON.stringify({ url })) // Client fetches directly
|
|
280
|
+
|
|
281
|
+
// Good: Proxy through server with ACL checks
|
|
282
|
+
const buffer = await downloadFile(path)
|
|
283
|
+
// ... validate ACL ...
|
|
284
|
+
return new Response(buffer, { headers: { 'Content-Type': contentType } })
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Trusting URL Parameters for ACL
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// Bad: Client-supplied conversationId used for access check
|
|
291
|
+
const convId = url.searchParams.get('conversationId')
|
|
292
|
+
|
|
293
|
+
// Good: Read conversationId from file metadata (server source of truth)
|
|
294
|
+
const metadata = await getFileMetadata(storagePath)
|
|
295
|
+
const convId = metadata.metadata?.conversationId
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Blocking Uploads on Moderation Failure
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
// Bad: API error blocks the upload entirely
|
|
302
|
+
const result = await VisionService.analyzeImage(buffer)
|
|
303
|
+
if (!result) throw new Error('Moderation failed') // Blocks upload
|
|
304
|
+
|
|
305
|
+
// Good: Fail open — allow upload if Vision API is unavailable
|
|
306
|
+
try {
|
|
307
|
+
const result = await VisionService.analyzeImage(buffer)
|
|
308
|
+
if (shouldReject(result)) { reject(); return }
|
|
309
|
+
} catch {
|
|
310
|
+
// Vision API down — allow upload (fail-open)
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Key Design Decisions
|
|
317
|
+
|
|
318
|
+
### Upload Architecture
|
|
319
|
+
|
|
320
|
+
| Decision | Choice | Rationale |
|
|
321
|
+
|---|---|---|
|
|
322
|
+
| Upload transport | WebSocket via Durable Object | Bypasses HTTP body size limits; enables progress tracking |
|
|
323
|
+
| Chunk size (client→DO) | 256KB | Stays under 1MiB Cloudflare WebSocket message limit after base64 |
|
|
324
|
+
| Chunk size (DO→Firebase) | 1MB | Optimal for Firebase resumable uploads |
|
|
325
|
+
| DO instance key | `idFromName(userId)` | One DO per user; consistent instance across uploads |
|
|
326
|
+
|
|
327
|
+
### Security
|
|
328
|
+
|
|
329
|
+
| Decision | Choice | Rationale |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| Storage rules | Deny-all-read | Forces all access through server-side ACL proxy |
|
|
332
|
+
| ACL source of truth | File metadata (not URL params) | Prevents parameter tampering |
|
|
333
|
+
| Moderation strategy | Fail-open, reject only VERY_LIKELY | Minimizes false positives while catching obvious violations |
|
|
334
|
+
| Video uploads | Blocked entirely | No moderation pipeline for video yet |
|
|
335
|
+
|
|
336
|
+
### Performance
|
|
337
|
+
|
|
338
|
+
| Decision | Choice | Rationale |
|
|
339
|
+
|---|---|---|
|
|
340
|
+
| Image proxy caching | `max-age=3300` (~55 min) | Balances freshness with CDN efficiency |
|
|
341
|
+
| Usage tracking | Fire-and-forget after upload | Don't block upload success on quota tracking |
|
|
342
|
+
| Image compression | Client-side to ≤1568px, 0.85 JPEG | Claude-optimal size; reduces upload time |
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Checklist for Implementation
|
|
347
|
+
|
|
348
|
+
- [ ] Storage path uses `STORAGE_BASE` constant, not hardcoded prefix
|
|
349
|
+
- [ ] New upload type added to `MediaStorageService` with correct path and metadata
|
|
350
|
+
- [ ] Content moderation configured for new image upload types (skip for non-images)
|
|
351
|
+
- [ ] Image proxy updated with ACL rules for new access patterns
|
|
352
|
+
- [ ] `UsageDatabaseService.incrementStorage()` called after successful upload
|
|
353
|
+
- [ ] Account deletion cleanup handles new storage paths
|
|
354
|
+
- [ ] Client-side image compression applied before upload (if applicable)
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Related Patterns
|
|
359
|
+
|
|
360
|
+
- **[Firebase Auth](./tanstack-cloudflare.firebase-auth.md)**: Auth verification required before upload and download operations
|
|
361
|
+
- **[Firebase Firestore](./tanstack-cloudflare.firebase-firestore.md)**: Crop metadata and usage tracking stored in Firestore
|
|
362
|
+
- **[Database Service Conventions](./database-service-conventions.md)**: MediaCropDatabaseService follows standard conventions
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
**Status**: Stable
|
|
367
|
+
**Recommendation**: Follow this pattern for all new file upload/download features. Always proxy through the image API — never expose signed URLs directly to clients.
|
|
368
|
+
**Last Updated**: 2026-03-14
|
|
369
|
+
**Contributors**: Community
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Form Controls: Slider & ToggleSwitch
|
|
2
|
+
|
|
3
|
+
**Category**: Design
|
|
4
|
+
**Applicable To**: Range inputs and boolean toggles
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Two reusable form control components: Slider (continuous/discrete range input with gradient fill) and ToggleSwitch (iOS-style boolean toggle with ARIA support). Both follow consistent dark theme styling and are fully keyboard accessible. For pagination controls (Paginator, PaginationToggle, InfiniteScrollSentinel, Virtuoso), see [Pagination Suite](./tanstack-cloudflare.pagination.md).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Implementation
|
|
16
|
+
|
|
17
|
+
### Slider
|
|
18
|
+
|
|
19
|
+
**File**: `src/components/Slider.tsx`
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// Continuous mode
|
|
23
|
+
interface SliderContinuousProps {
|
|
24
|
+
min: number
|
|
25
|
+
max: number
|
|
26
|
+
step: number
|
|
27
|
+
value: number
|
|
28
|
+
onChange: (value: number) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Discrete mode
|
|
32
|
+
interface SliderDiscreteProps {
|
|
33
|
+
options: Array<{ value: number; label?: string }>
|
|
34
|
+
value: number
|
|
35
|
+
onChange: (value: number) => void
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Features**:
|
|
40
|
+
- **Continuous**: Standard min/max/step range input
|
|
41
|
+
- **Discrete**: Snaps to predefined option values, optional labels below
|
|
42
|
+
- **Gradient fill**: `linear-gradient(90deg, #3b82f6 0%, #8b5cf6 ${pct}%, rgb(55 65 81) ${pct}%)`
|
|
43
|
+
- Custom CSS class `slider-styled` in `styles.css`:
|
|
44
|
+
- Thumb: 20px circle, box-shadow, -7px margin-top
|
|
45
|
+
- Track: 6px height, 3px border-radius
|
|
46
|
+
- Keyboard: Left/Right arrows adjust value
|
|
47
|
+
|
|
48
|
+
**Usage**:
|
|
49
|
+
```typescript
|
|
50
|
+
<Slider min={0} max={100} step={5} value={volume} onChange={setVolume} />
|
|
51
|
+
|
|
52
|
+
<Slider
|
|
53
|
+
options={[
|
|
54
|
+
{ value: 0, label: 'Off' },
|
|
55
|
+
{ value: 50, label: 'Medium' },
|
|
56
|
+
{ value: 100, label: 'Max' },
|
|
57
|
+
]}
|
|
58
|
+
value={level}
|
|
59
|
+
onChange={setLevel}
|
|
60
|
+
/>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
### ToggleSwitch
|
|
66
|
+
|
|
67
|
+
**File**: `src/components/ToggleSwitch.tsx`
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
interface ToggleSwitchProps {
|
|
71
|
+
checked: boolean
|
|
72
|
+
onChange: (checked: boolean) => void
|
|
73
|
+
size?: 'sm' | 'md' | 'lg' // default: 'md'
|
|
74
|
+
label?: string
|
|
75
|
+
description?: string
|
|
76
|
+
disabled?: boolean
|
|
77
|
+
id?: string
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Features**:
|
|
82
|
+
- iOS-style toggle with animated knob
|
|
83
|
+
- **Checked**: gradient background `from-purple-600 to-blue-600`, checkmark inside knob
|
|
84
|
+
- **Unchecked**: `bg-gray-700`, plain knob
|
|
85
|
+
- Size presets (md: track w-11 h-6, knob w-5 h-5)
|
|
86
|
+
- Keyboard: Space/Enter toggles
|
|
87
|
+
- `role="switch"`, `aria-checked` for accessibility
|
|
88
|
+
- `focus-visible:ring-2 ring-purple-500` focus ring
|
|
89
|
+
- Optional label + description text
|
|
90
|
+
- Disabled: `opacity-50 cursor-not-allowed`
|
|
91
|
+
|
|
92
|
+
**Usage**:
|
|
93
|
+
```typescript
|
|
94
|
+
<ToggleSwitch
|
|
95
|
+
checked={darkMode}
|
|
96
|
+
onChange={setDarkMode}
|
|
97
|
+
label="Dark Mode"
|
|
98
|
+
description="Use dark color scheme throughout the app"
|
|
99
|
+
/>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Anti-Patterns
|
|
105
|
+
|
|
106
|
+
### Using Native Checkbox Instead of ToggleSwitch
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// Bad: Inconsistent with app design
|
|
110
|
+
<input type="checkbox" checked={value} onChange={...} />
|
|
111
|
+
|
|
112
|
+
// Good: Use ToggleSwitch for visual consistency
|
|
113
|
+
<ToggleSwitch checked={value} onChange={setValue} label="Enable feature" />
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Inline Range Input Instead of Slider
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
// Bad: No gradient fill, no discrete mode support
|
|
120
|
+
<input type="range" min={0} max={100} />
|
|
121
|
+
|
|
122
|
+
// Good: Use Slider with gradient and optional discrete options
|
|
123
|
+
<Slider min={0} max={100} step={1} value={val} onChange={setVal} />
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Checklist
|
|
129
|
+
|
|
130
|
+
- [ ] Use `Slider` for any range/value selection (not raw `<input type="range">`)
|
|
131
|
+
- [ ] Use `ToggleSwitch` for boolean settings (not checkboxes)
|
|
132
|
+
- [ ] ToggleSwitch has `role="switch"` and `aria-checked`
|
|
133
|
+
- [ ] All controls are keyboard accessible
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Related Patterns
|
|
138
|
+
|
|
139
|
+
- **[Pagination Suite](./tanstack-cloudflare.pagination.md)**: Paginator, PaginationToggle, InfiniteScrollSentinel, Virtuoso patterns
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
**Status**: Stable
|
|
144
|
+
**Last Updated**: 2026-03-14
|
|
145
|
+
**Contributors**: Community
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Global Search Context
|
|
2
|
+
|
|
3
|
+
**Category**: Architecture
|
|
4
|
+
**Applicable To**: Cross-component state sharing without Redux — search queries, filters, or any key-scoped shared state
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
A lightweight pub/sub mechanism using React Context + `useRef` that enables multiple components to share state by key without Redux. Components call `useGlobalSearch(key)` and get a `[value, setValue]` tuple. Setting a value notifies only subscribers of that key, avoiding unnecessary renders for unrelated keys.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Implementation
|
|
16
|
+
|
|
17
|
+
**File**: `src/contexts/GlobalSearchContext.tsx`
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
interface Store {
|
|
21
|
+
values: Map<string, string>
|
|
22
|
+
subscribers: Map<string, Set<() => void>>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const GlobalSearchContext = createContext<Store>(/* ... */)
|
|
26
|
+
|
|
27
|
+
function GlobalSearchProvider({ children }: { children: ReactNode }) {
|
|
28
|
+
const storeRef = useRef<Store>({
|
|
29
|
+
values: new Map(),
|
|
30
|
+
subscribers: new Map(),
|
|
31
|
+
})
|
|
32
|
+
return (
|
|
33
|
+
<GlobalSearchContext.Provider value={storeRef.current}>
|
|
34
|
+
{children}
|
|
35
|
+
</GlobalSearchContext.Provider>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function useGlobalSearch(key: string): [string, (value: string) => void] {
|
|
40
|
+
const store = useContext(GlobalSearchContext)
|
|
41
|
+
const [, setTick] = useState(0) // Force re-render
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const bump = () => setTick(t => t + 1)
|
|
45
|
+
if (!store.subscribers.has(key)) store.subscribers.set(key, new Set())
|
|
46
|
+
store.subscribers.get(key)!.add(bump)
|
|
47
|
+
return () => { store.subscribers.get(key)?.delete(bump) }
|
|
48
|
+
}, [store, key])
|
|
49
|
+
|
|
50
|
+
const value = store.values.get(key) ?? ''
|
|
51
|
+
|
|
52
|
+
const setValue = useCallback((v: string) => {
|
|
53
|
+
const current = store.values.get(key) ?? ''
|
|
54
|
+
if (current === v) return // Skip if unchanged
|
|
55
|
+
if (v) store.values.set(key, v)
|
|
56
|
+
else store.values.delete(key)
|
|
57
|
+
store.subscribers.get(key)?.forEach(fn => fn())
|
|
58
|
+
}, [store, key])
|
|
59
|
+
|
|
60
|
+
return [value, setValue]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Usage**:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// Component A (search input)
|
|
68
|
+
const [query, setQuery] = useGlobalSearch('memories:query')
|
|
69
|
+
<input value={query} onChange={e => setQuery(e.target.value)} />
|
|
70
|
+
|
|
71
|
+
// Component B (feed list) — subscribes to same key
|
|
72
|
+
const [query] = useGlobalSearch('memories:query')
|
|
73
|
+
// Re-renders only when memories:query changes
|
|
74
|
+
|
|
75
|
+
// Component C (different key) — NOT re-rendered
|
|
76
|
+
const [filter] = useGlobalSearch('conversations:filter')
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Key conventions**: `{page}:{field}` (e.g., `memories:query`, `conversations:filter`, `messages:query`)
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Checklist
|
|
84
|
+
|
|
85
|
+
- [ ] Wrap app in `<GlobalSearchProvider>` (in root layout)
|
|
86
|
+
- [ ] Use key convention `{page}:{field}` for scope isolation
|
|
87
|
+
- [ ] Don't use for non-search state — this is optimized for string values
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
**Status**: Stable
|
|
92
|
+
**Last Updated**: 2026-03-14
|
|
93
|
+
**Contributors**: Community
|