Skip to content

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 actions

MediaPicker

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

LevelWho Can See
allAll project members
admin-onlyUsers with MANAGE_USERS permission
owner-onlyOnly 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


Last updated: January 2026