Cloud File Repository Architecture
Firebase Storage-backed file management with versioning and offline support.
Overview
The Cloud File Repository provides a full-featured file management system integrated into the production workflow. It handles:
- File uploads up to 200MB with progress tracking
- Folder organization with nested directory structures
- Version history with automatic version numbering
- Soft delete with 30-day trash retention
- Offline caching via IndexedDB
- Permission-based visibility with owner, admin, and public scopes
Architecture
Storage Layer
Firebase Storage
├── projects/{projectId}/files/
│ ├── {folderId}/
│ │ └── {fileId}_{version}.{ext}
│ └── ...
└── ...Metadata Layer
Firestore
├── projects/{projectId}/
│ └── files/
│ ├── {fileId} // FileNode document
│ └── ...Type Definitions
typescript
// src/features/files/types.ts
export type FileNodeType = 'file' | 'folder';
export type FileVisibility = 'all' | 'admin-only' | 'owner-only';
export interface FileNode {
id: string;
projectId: string;
parentId: string; // 'root' for top-level
name: string;
type: FileNodeType;
mimeType?: string;
size?: number;
url?: string;
thumbnailUrl?: string;
storagePath?: string;
createdAt: number;
createdBy: string;
updatedAt: number;
// Versioning
versionNumber: number;
previousVersionId?: string;
// Permissions
visibility: FileVisibility;
readOnly: boolean;
// Soft Delete
deleted: boolean;
deletedAt?: number;
}
export interface StorageQuota {
usedBytes: number;
quotaBytes: number; // Default: 1GB per project
}Core Components
FileExplorer
The main file browser interface:
src/features/files/components/
├── FileExplorer.tsx // Main container
├── FileGrid.tsx // Grid view of files
├── FileList.tsx // List view of files
├── FileRow.tsx // Individual file row
├── FolderBreadcrumbs.tsx // Navigation breadcrumbs
├── FileUploader.tsx // Upload dropzone
├── FilePreview.tsx // Preview modal
└── FileContextMenu.tsx // Right-click actionsMediaPicker
Modal for selecting files from the repository (used in Feed, Sound, Sets):
typescript
import { MediaPicker } from '@/features/files';
<MediaPicker
onSelect={(file) => handleFileSelected(file)}
accept={['image/*', 'audio/*']}
multiple={false}
/>UniversalFilePicker
Combined upload + repository selection interface:
typescript
import { UniversalFilePicker } from '@/features/files';
<UniversalFilePicker
onFileSelect={(file) => handleFile(file)}
allowUpload={true}
allowRepository={true}
accept="image/*"
/>Store Architecture
Zustand Slice
typescript
// src/features/files/store.ts
interface FilesState {
// State
nodes: FileNode[];
currentFolderId: string;
selectedIds: Set<string>;
viewMode: ViewMode;
sortField: SortField;
sortDirection: SortDirection;
searchQuery: string;
quota: StorageQuota;
// Actions
uploadFile: (file: File, options: FileUploadOptions) => Promise<FileNode>;
createFolder: (name: string, parentId: string) => Promise<FileNode>;
deleteNode: (id: string) => Promise<void>; // Soft delete
restoreNode: (id: string) => Promise<void>;
permanentDelete: (id: string) => Promise<void>;
moveNode: (id: string, newParentId: string) => Promise<void>;
renameNode: (id: string, newName: string) => Promise<void>;
// Versioning
uploadNewVersion: (nodeId: string, file: File) => Promise<FileNode>;
getVersionHistory: (nodeId: string) => FileNode[];
// Offline
markForOffline: (id: string) => Promise<void>;
removeFromOffline: (id: string) => Promise<void>;
}Upload Flow
Standard Upload
typescript
async function uploadFile(file: File, options: FileUploadOptions): Promise<FileNode> {
// 1. Generate unique storage path
const storagePath = `projects/${options.projectId}/files/${uuid()}_v1_${file.name}`;
// 2. Upload to Firebase Storage with progress
const uploadTask = uploadBytesResumable(storageRef, file);
uploadTask.on('state_changed', (snapshot) => {
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
options.onProgress?.(progress);
});
// 3. Get download URL
const url = await getDownloadURL(uploadTask.snapshot.ref);
// 4. Create Firestore metadata
const node: FileNode = {
id: uuid(),
projectId: options.projectId,
parentId: options.parentId,
name: file.name,
type: 'file',
mimeType: file.type,
size: file.size,
url,
storagePath,
createdAt: Date.now(),
createdBy: currentUserId,
updatedAt: Date.now(),
versionNumber: 1,
visibility: 'all',
readOnly: false,
deleted: false,
};
// 5. Save to Firestore
await setDoc(doc(db, `projects/${options.projectId}/files`, node.id), node);
return node;
}Version Upload
When uploading a new version of an existing file:
typescript
async function uploadNewVersion(nodeId: string, file: File): Promise<FileNode> {
const existing = await getNode(nodeId);
// New version with incremented number
const newNode: FileNode = {
...existing,
id: uuid(),
versionNumber: existing.versionNumber + 1,
previousVersionId: existing.id,
size: file.size,
updatedAt: Date.now(),
storagePath: `projects/${projectId}/files/${uuid()}_v${existing.versionNumber + 1}_${file.name}`,
};
// Upload and save
// ... same flow as standard upload
}Soft Delete & Trash
Deletion Flow
typescript
async function deleteNode(id: string): Promise<void> {
await updateDoc(doc(db, `projects/${projectId}/files`, id), {
deleted: true,
deletedAt: Date.now(),
});
// File remains in Storage until permanent deletion
}Trash Cleanup (Cloud Function)
A scheduled Cloud Function permanently deletes files after 30 days:
typescript
// functions/src/scheduled/cleanupTrash.ts
export const cleanupTrash = onSchedule('every 24 hours', async () => {
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
const trashedFiles = await getDocs(
query(
collectionGroup(db, 'files'),
where('deleted', '==', true),
where('deletedAt', '<=', thirtyDaysAgo)
)
);
for (const doc of trashedFiles.docs) {
// Delete from Storage
await deleteObject(ref(storage, doc.data().storagePath));
// Delete Firestore document
await deleteDoc(doc.ref);
}
});Offline Caching
IndexedDB Storage
Files marked for offline are cached in IndexedDB:
typescript
// Mark file for offline access
async function markForOffline(nodeId: string): Promise<void> {
const node = await getNode(nodeId);
// Fetch file blob
const response = await fetch(node.url);
const blob = await response.blob();
// Store in IndexedDB
await idbSet(`offline-file-${nodeId}`, {
node,
blob,
cachedAt: Date.now(),
});
}
// Get file (online or offline)
async function getFileBlob(nodeId: string): Promise<Blob> {
// Try IndexedDB first
const cached = await idbGet(`offline-file-${nodeId}`);
if (cached) return cached.blob;
// Fall back to network
const node = await getNode(nodeId);
const response = await fetch(node.url);
return response.blob();
}Visibility & Permissions
FileVisibility Levels
| Level | Who Can See |
|---|---|
all | All project members |
admin-only | Users with MANAGE_USERS permission |
owner-only | Only the file creator |
Security Rules
javascript
// firestore.rules
match /projects/{projectId}/files/{fileId} {
allow read: if isProjectMember() && (
resource.data.visibility == 'all' ||
(resource.data.visibility == 'admin-only' && hasPermission('MANAGE_USERS')) ||
(resource.data.visibility == 'owner-only' && resource.data.createdBy == request.auth.uid)
);
allow write: if isProjectMember() && !resource.data.readOnly;
}Integration Points
Feed Attachments
typescript
// When posting to feed, use MediaPicker
<MediaPicker
onSelect={(files) => setAttachments(files)}
accept={['image/*', 'video/*', 'audio/*']}
multiple={true}
/>Sound Library
typescript
// Import audio from repository
<UniversalFilePicker
onFileSelect={(file) => addToSoundboard(file)}
accept="audio/*"
/>Costume/Props Images
typescript
// Attach image to costume piece
<UniversalFilePicker
onFileSelect={(file) => setCostumeImage(file)}
accept="image/*"
/>Storage Quota
Quota Management
- Default quota: 1GB per project
- Displayed in Files UI with color-coded warnings:
- 🟢 Green: < 70% used
- 🟡 Yellow: 70-90% used
- 🔴 Red: > 90% used
Quota Calculation
typescript
async function calculateQuota(projectId: string): Promise<StorageQuota> {
const files = await getDocs(
query(
collection(db, `projects/${projectId}/files`),
where('deleted', '==', false),
where('type', '==', 'file')
)
);
const usedBytes = files.docs.reduce((sum, doc) => sum + (doc.data().size || 0), 0);
return {
usedBytes,
quotaBytes: 1024 * 1024 * 1024, // 1GB
};
}Further Reading
- Cloud Sync Architecture — Real-time synchronization
- State Management — Zustand store patterns
Last updated: January 2026