Bart Dorsey

Frontend API Layer

Project Structure

The frontend is built with React 19 and TypeScript, organized into feature-based folders:

src/
├── api/
│   └── photos-api.ts           # All network calls
├── features/
│   ├── photos/                 # Photo display components
│   └── upload/
│       ├── SimpleUploadForm.tsx
│       └── UploadForm.tsx
└── App.tsx                     # Routing

Keeping all API calls in one file means components never call fetch directly — they only deal with typed results.


The Result Pattern

Rather than throwing exceptions on failure, every API function returns an ApiResult<T> — a discriminated union that forces callers to handle both outcomes:

frontend/src/api/photos-api.ts#L12-L18 on GitHub

// src/api/photos-api.ts

export type ApiResult<T> = {
    success: true;
    data: T;
} | {
    success: false;
    error: string;
};

TypeScript narrows the type automatically when you check result.success:

const result = await uploadPhoto(formData);

if (result.success) {
    console.log(result.data); // TypeScript knows this is PhotoResponse
} else {
    console.log(result.error); // TypeScript knows this is string
}

Accessing result.data without checking result.success first is a type error — TypeScript enforces correct error handling at compile time.


Response Types

frontend/src/api/photos-api.ts#L4-L9 on GitHub

export type PhotoResponse = {
    id: number;
    photo_url: string;
    title?: string;
    description?: string;
};

This mirrors the PhotoResponse Pydantic model on the backend.


API Functions

frontend/src/api/photos-api.ts#L23-L86 on GitHub

const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:8000";

export async function getAllPhotos(): Promise<ApiResult<PhotoResponse[]>> {
    try {
        const response = await fetch(`${baseUrl}/api/photos`);
        if (!response.ok) {
            return { success: false, error: "Couldn't fetch photos" };
        }
        const photos = await response.json();
        return { success: true, data: photos };
    } catch (e) {
        return {
            success: false,
            error: e instanceof Error ? e.message : "Unknown error occurred"
        };
    }
}

export async function uploadPhoto(formData: FormData): Promise<ApiResult<PhotoResponse>> {
    try {
        const response = await fetch(`${baseUrl}/api/photos`, {
            method: "POST",
            body: formData,
        });

        if (!response.ok) {
            return { success: false, error: "Unable to upload image" };
        }

        const photo = await response.json();
        return { success: true, data: photo };
    } catch (e) {
        return {
            success: false,
            error: e instanceof Error ? e.message : "Unknown error occurred"
        };
    }
}

Why this approach?

Uploading with FormData

The uploadPhoto function passes FormData directly as the request body. The browser sets the correct Content-Type: multipart/form-data header automatically — you should not set it manually, as doing so omits the boundary string that the server needs to parse the body.