# Upload widget and media component

The JavaScript SDK includes two front-end components. The upload widget sends files from the browser to CalmLens and shows each file's progress and moderation decision. The media component shows an asset from the CDN in a way that fits its moderation status. Both come in the same form for React, Vue, Svelte and plain HTML, so the code below follows the library you choose.

## Choose your library

Install `@calmlens/sdk` and import the components for your UI library:

| Import                   | Components                                                                                                                                          |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@calmlens/sdk/react`    | `CalmLensUploader`, `CalmLensMedia` and the `useCalmLensUpload` hook. Also works in Preact through `preact/compat`.                                 |
| `@calmlens/sdk/vue`      | `CalmLensUploader`, `CalmLensMedia` and the `useCalmLensUpload` composable, for Vue 3.                                                              |
| `@calmlens/sdk/svelte`   | `CalmLensUploader` and `CalmLensMedia`, for Svelte 5.                                                                                               |
| `@calmlens/sdk/elements` | The `<calmlens-uploader>` and `<calmlens-media>` custom elements, for plain HTML, Angular, Solid, Lit and any library that renders custom elements. |

Importing `@calmlens/sdk/elements` defines both elements. The components share one core, `@calmlens/sdk/ui`, which you can use to build your own interface on the same upload queue.

## Add the upload widget

The widget uploads through an upload preset, so files go straight from the browser to CalmLens and your API key stays on your server. Create a preset that requires a token and shows the decision to the uploader, as in [Build an upload component](/docs/guides/upload-component#1-create-an-upload-preset), then add an endpoint that signs a token for each file:

**JavaScript/TypeScript Server SDK**

```typescript
const { token, expiresAt } = await client.uploadTokens.sign({
  uploadPresetId: "your-upload-preset-id",
  externalId: "post-42",
  expiresInSeconds: 600,
});
```

**python**

```python
signed = client.upload_tokens.sign(
    upload_preset_id="your-upload-preset-id",
    external_id="post-42",
    expires_in_seconds=600,
)
print(signed["token"], signed["expiresAt"])
```

**go**

```go
package main

import (
    "context"
    "fmt"
    "log"

    calmlens "github.com/CalmLens/sdks/go"
)

func example(client *calmlens.ServerClient) {

    signed, err := client.UploadTokens.Sign(context.Background(), calmlens.SignUploadTokenOptions{
        UploadPresetID: "your-upload-preset-id",
        ExternalID: calmlens.Ptr("post-42"),
        ExpiresInSeconds: 600,
    })
    if err != nil { log.Fatal(err) }
    fmt.Println(signed.Token, signed.ExpiresAt)
}
```

**ruby**

```ruby
signed = client.upload_tokens.sign(upload_preset_id: "your-upload-preset-id", external_id: "post-42", expires_in_seconds: 600)
puts signed["token"], signed["expiresAt"]
```

**rust**

```rust
use calmlens::{models::*, ServerClient};

fn example(client: &ServerClient) -> Result<(), Box<dyn std::error::Error>> {
    let signed = client.upload_tokens().sign(SignUploadToken { external_id: Some("post-42".into()), expires_in_seconds: Some(600), ..SignUploadToken::new("your-upload-preset-id") })?;
    println!("{} {}", signed.token, signed.expires_at);
    Ok(())
}
```

**java**

```java
import com.calmlens.CalmLensServerClient;
import com.calmlens.Models.*;

public class Example {
  public static void example(CalmLensServerClient client) {
    var signed = client.uploadTokens.sign(new SignUploadToken("your-upload-preset-id").externalId("post-42").expiresInSeconds(600));
    System.out.println(signed.token() + " " + signed.expiresAt());
  }
}
```

Point `tokenUrl` at that endpoint. The widget sends it a `POST` with the file's `name`, `type` and `size` as JSON and expects `{ token }` back. To fetch tokens another way, pass `token`, a function that receives the file and returns a token.

**React**

```tsx
import { type CalmLensUpload, CalmLensUploader } from "@calmlens/sdk/react";

export function PhotoUploader({ onUploaded }: { onUploaded: (upload: CalmLensUpload) => void }) {
  return (
    <CalmLensUploader
      uploadPresetId="your-upload-preset-id"
      tokenUrl="/api/upload-token"
      accept="image/*"
      maxFileSize={10 * 1024 * 1024}
      multiple
      onUploaded={onUploaded}
    />
  );
}
```

**Vue**

```vue
<script setup lang="ts">
import { type CalmLensUpload, CalmLensUploader } from "@calmlens/sdk/vue";

const emit = defineEmits<{ uploaded: [upload: CalmLensUpload] }>();
</script>

<template>
  <CalmLensUploader
    upload-preset-id="your-upload-preset-id"
    token-url="/api/upload-token"
    accept="image/*"
    :max-file-size="10 * 1024 * 1024"
    multiple
    @uploaded="(upload) => emit('uploaded', upload)"
  />
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import { type CalmLensUpload, CalmLensUploader } from "@calmlens/sdk/svelte";

  let { onUploaded }: { onUploaded: (upload: CalmLensUpload) => void } = $props();
</script>

<CalmLensUploader
  uploadPresetId="your-upload-preset-id"
  tokenUrl="/api/upload-token"
  accept="image/*"
  maxFileSize={10 * 1024 * 1024}
  multiple
  {onUploaded}
/>
```

**Plain JavaScript**

```javascript
// Defines <calmlens-uploader> and <calmlens-media>. In plain HTML, write the element in your
// markup instead: <calmlens-uploader upload-preset-id="…" token-url="/api/upload-token">.
import "@calmlens/sdk/elements";

export function mountPhotoUploader(container, { onUploaded }) {
  const uploader = document.createElement("calmlens-uploader");
  uploader.setAttribute("upload-preset-id", "your-upload-preset-id");
  uploader.setAttribute("token-url", "/api/upload-token");
  uploader.setAttribute("accept", "image/*");
  uploader.setAttribute("max-file-size", String(10 * 1024 * 1024));
  uploader.toggleAttribute("multiple", true);
  uploader.addEventListener("calmlens-uploaded", (event) => onUploaded(event.detail));
  container.replaceChildren(uploader);
}
```

For each file, the widget:

- Refuses files that don't match `accept` or are larger than `maxFileSize` before sending anything. Your preset's own file types and size limit still apply.
- Shows the bytes sent, then checks the upload until it has a decision, for up to 15 seconds by default (`decisionTimeoutMs`). An upload still `pending` after that is left for your server's webhook to settle.
- Shows the decision: `allowed`, `limited`, `age-restricted`, `blocked` or `purged`. Errors such as [`413 upload-preset/too-large`](/docs/api/assets#post-asset-error-upload-preset-too-large) get their own message, and a failed upload can be retried.
- Calls `onUploaded` with `{ assetId, status, file }` unless the file was blocked or purged. Vue emits `uploaded`, and the custom element dispatches a `calmlens-uploaded` event with the same `detail`.

Send the asset ID to your server with the record it belongs to, and publish from the status your server reads from CalmLens, as described in [Save the asset ID with the post](/docs/guides/upload-component#4-save-the-asset-id-with-the-post).

`multiple` accepts several files, `maxFiles` caps how many (10 by default) and `concurrency` sets how many upload at once (3 by default). `capture` opens the camera on phones.

## Upload through your own API

When files must pass through your server, for example to attach them to a signed-in user's records, pass an `upload` function instead of an upload preset. It receives the file with an abort `signal` and an `onProgress` callback, and returns the asset ID and, if your API has one, the moderation status. `createProgressFetch(onProgress)` returns a `fetch` that reports the bytes sent. Add `checkStatus` so the widget can check a pending upload; without it, the widget shows the upload as pending.

**React**

```tsx
import {
  type CalmLensUpload,
  CalmLensUploader,
  type CustomUploadContext,
  createProgressFetch,
} from "@calmlens/sdk/react";

// Your server stores the file with CalmLensServerClient and answers with the asset.
async function uploadPhoto(file: File, { signal, onProgress }: CustomUploadContext) {
  const send = createProgressFetch(onProgress);
  const response = await send("/api/photos", {
    method: "POST",
    headers: { "Content-Type": file.type, "X-File-Name": encodeURIComponent(file.name) },
    body: file,
    signal,
  });
  if (!response.ok) throw new Error(`Upload failed (${response.status})`);
  const { assetId, status } = await response.json();
  return { assetId, status };
}

async function checkPhoto(assetId: string, { signal }: { signal: AbortSignal }) {
  const response = await fetch(`/api/photos/${assetId}`, { signal });
  return (await response.json()).status;
}

export function OwnApiUploader({ onUploaded }: { onUploaded: (upload: CalmLensUpload) => void }) {
  return (
    <CalmLensUploader
      upload={uploadPhoto}
      checkStatus={checkPhoto}
      accept="image/*"
      onUploaded={onUploaded}
    />
  );
}
```

**Vue**

```vue
<script setup lang="ts">
import {
  type CalmLensUpload,
  CalmLensUploader,
  type CustomUploadContext,
  createProgressFetch,
} from "@calmlens/sdk/vue";

const emit = defineEmits<{ uploaded: [upload: CalmLensUpload] }>();

// Your server stores the file with CalmLensServerClient and answers with the asset.
async function uploadPhoto(file: File, { signal, onProgress }: CustomUploadContext) {
  const send = createProgressFetch(onProgress);
  const response = await send("/api/photos", {
    method: "POST",
    headers: { "Content-Type": file.type, "X-File-Name": encodeURIComponent(file.name) },
    body: file,
    signal,
  });
  if (!response.ok) throw new Error(`Upload failed (${response.status})`);
  const { assetId, status } = await response.json();
  return { assetId, status };
}

async function checkPhoto(assetId: string, { signal }: { signal: AbortSignal }) {
  const response = await fetch(`/api/photos/${assetId}`, { signal });
  return (await response.json()).status;
}
</script>

<template>
  <CalmLensUploader
    :upload="uploadPhoto"
    :check-status="checkPhoto"
    accept="image/*"
    @uploaded="(upload) => emit('uploaded', upload)"
  />
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import {
    type CalmLensUpload,
    CalmLensUploader,
    type CustomUploadContext,
    createProgressFetch,
  } from "@calmlens/sdk/svelte";

  let { onUploaded }: { onUploaded: (upload: CalmLensUpload) => void } = $props();

  // Your server stores the file with CalmLensServerClient and answers with the asset.
  async function uploadPhoto(file: File, { signal, onProgress }: CustomUploadContext) {
    const send = createProgressFetch(onProgress);
    const response = await send("/api/photos", {
      method: "POST",
      headers: { "Content-Type": file.type, "X-File-Name": encodeURIComponent(file.name) },
      body: file,
      signal,
    });
    if (!response.ok) throw new Error(`Upload failed (${response.status})`);
    const { assetId, status } = await response.json();
    return { assetId, status };
  }

  async function checkPhoto(assetId: string, { signal }: { signal: AbortSignal }) {
    const response = await fetch(`/api/photos/${assetId}`, { signal });
    return (await response.json()).status;
  }
</script>

<CalmLensUploader upload={uploadPhoto} checkStatus={checkPhoto} accept="image/*" {onUploaded} />
```

**Plain JavaScript**

```javascript
import { createProgressFetch } from "@calmlens/sdk/elements";

// Your server stores the file with CalmLensServerClient and answers with the asset.
async function uploadPhoto(file, { signal, onProgress }) {
  const send = createProgressFetch(onProgress);
  const response = await send("/api/photos", {
    method: "POST",
    headers: { "Content-Type": file.type, "X-File-Name": encodeURIComponent(file.name) },
    body: file,
    signal,
  });
  if (!response.ok) throw new Error(`Upload failed (${response.status})`);
  const { assetId, status } = await response.json();
  return { assetId, status };
}

async function checkPhoto(assetId, { signal }) {
  const response = await fetch(`/api/photos/${assetId}`, { signal });
  return (await response.json()).status;
}

export function mountOwnApiUploader(container, { onUploaded }) {
  const uploader = document.createElement("calmlens-uploader");
  uploader.setAttribute("accept", "image/*");
  uploader.upload = uploadPhoto;
  uploader.checkStatus = checkPhoto;
  uploader.addEventListener("calmlens-uploaded", (event) => onUploaded(event.detail));
  container.replaceChildren(uploader);
}
```

Throw an error to show the file as failed. A `CalmLensError` shows the message for its code, so your API's error codes can have their own messages.

## Show media

The media component shows an asset from the CDN. Pass its project, ID and stored moderation status, or an asset from the API as `asset`, which also supplies its media type and stored preview (placeholder, average colour and size); props you set win over the asset's values. Pass `canView` when your app lets this viewer see age-restricted media:

**React**

```tsx
import type { AssetModerationStatus } from "@calmlens/sdk/app";
import { CalmLensMedia } from "@calmlens/sdk/react";

type PostPhotoProps = {
  projectId: string;
  assetId: string;
  status: AssetModerationStatus;
  alt: string;
  /** Width divided by height, which you store when the image is uploaded. */
  aspectRatio: number;
  canView: boolean;
  onReveal: () => void;
};

export function PostPhoto({
  projectId,
  assetId,
  status,
  alt,
  aspectRatio,
  canView,
  onReveal,
}: PostPhotoProps) {
  return (
    <CalmLensMedia
      projectId={projectId}
      assetId={assetId}
      status={status}
      canView={canView}
      onReveal={onReveal}
      alt={alt}
      aspectRatio={aspectRatio}
      widths={[400, 800]}
      sizes="(min-width: 768px) 50vw, 100vw"
    />
  );
}
```

**Vue**

```vue
<script setup lang="ts">
import type { AssetModerationStatus } from "@calmlens/sdk/app";
import { CalmLensMedia } from "@calmlens/sdk/vue";

defineProps<{
  projectId: string;
  assetId: string;
  status: AssetModerationStatus;
  alt: string;
  /** Width divided by height, which you store when the image is uploaded. */
  aspectRatio: number;
  canView: boolean;
}>();
const emit = defineEmits<{ reveal: [] }>();
</script>

<template>
  <CalmLensMedia
    :project-id="projectId"
    :asset-id="assetId"
    :status="status"
    :can-view="canView"
    :alt="alt"
    :aspect-ratio="aspectRatio"
    :widths="[400, 800]"
    sizes="(min-width: 768px) 50vw, 100vw"
    @reveal="emit('reveal')"
  />
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import type { AssetModerationStatus } from "@calmlens/sdk/app";
  import { CalmLensMedia } from "@calmlens/sdk/svelte";

  type Props = {
    projectId: string;
    assetId: string;
    status: AssetModerationStatus;
    alt: string;
    /** Width divided by height, which you store when the image is uploaded. */
    aspectRatio: number;
    canView: boolean;
    onReveal: () => void;
  };
  let { projectId, assetId, status, alt, aspectRatio, canView, onReveal }: Props = $props();
</script>

<CalmLensMedia
  {projectId}
  {assetId}
  {status}
  {canView}
  {onReveal}
  {alt}
  {aspectRatio}
  widths={[400, 800]}
  sizes="(min-width: 768px) 50vw, 100vw"
/>
```

**Plain JavaScript**

```javascript
import "@calmlens/sdk/elements";

// aspectRatio is width divided by height, which you store when the image is uploaded.
export function renderPostPhoto(
  container,
  { projectId, assetId, status, alt, aspectRatio, canView, onReveal },
) {
  const media = document.createElement("calmlens-media");
  media.setAttribute("project-id", projectId);
  media.setAttribute("asset-id", assetId);
  media.setAttribute("status", status);
  media.setAttribute("alt", alt);
  media.setAttribute("aspect-ratio", String(aspectRatio));
  media.setAttribute("widths", "400,800");
  media.setAttribute("sizes", "(min-width: 768px) 50vw, 100vw");
  media.toggleAttribute("can-view", canView);
  media.addEventListener("calmlens-reveal", onReveal);
  container.replaceChildren(media);
}
```

| Status                                                      | What the component shows                                                                                                        |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `allowed`, `limited` or `pending`                           | The media, with the blurred placeholder until it loads                                                                          |
| `age-restricted`                                            | The placeholder with a notice and a reveal button until you pass `canView`, then the media with the `allowAgeRestricted` opt-in |
| `blocked` or `purged`                                       | An unavailable notice, without requesting the media                                                                             |
| Any status, when an `asset`'s `content.status` is `removed` | An unavailable notice, without requesting the media                                                                             |

The reveal button calls `onReveal`, where your app runs its own check before rendering again with `canView`; [Show sensitive media](/docs/guides/sensitive-media) covers that decision. A media URL that fails to load also shows the unavailable notice.

- Images come from the asset's rendered resource, so the CDN serves the best format the browser accepts without a list of formats. `widths` and `sizes` add a `srcset`, so browsers fetch the smallest image that fits. Each width is a separate image transform; see [Lenses and transforms](/docs/guides/lenses).
- `lens` and `transform` apply a named lens or a direct transform.
- `aspectRatio` (or `width` and `height`) keeps the box its final size while the image loads.
- `placeholderHash` takes the asset's `content.image.thumbhash` and draws the placeholder in the browser, with no placeholder request. It also works for private assets and signed URLs.
- `url` takes a signed URL from your server instead of building a public CDN URL, for private assets or when CalmLens must refuse age-restricted content to anyone your backend hasn't approved.
- `type` is `image` (default), `video` or `audio`. Video and audio play the original file, with no poster or placeholder.

## Style and translate

The components add a small stylesheet to the page the first time they render. Change the colours and corners with CSS custom properties such as `--calmlens-accent` and `--calmlens-radius`; your values take precedence on any element. Pass `unstyled` to leave the stylesheet out and style the `calmlens-*` classes yourself.

All text can be replaced through `messages`, keyed by message name (`dropzoneTitle`, `uploading`, `reveal`) or by error code, which also translates the components.

When you render on the server, or your Content Security Policy refuses injected styles, link `@calmlens/sdk/styles.css` or put `CALMLENS_STYLES` in a `<style>` in your page's head, and pass `unstyled`. The media component then keeps its layout before your scripts load.

## Test without a network

`MockCalmLensAppClient` from `@calmlens/sdk/testing` stands in for the uploader's client. It applies upload preset rules, such as a required token and size limits, with the API's error codes, and stores uploads in a `MockCalmLensServerClient`, whose CDN serves them with the same moderation rules:

```tsx
import { CalmLensUploader } from "@calmlens/sdk/react";
import { MockCalmLensAppClient } from "@calmlens/sdk/testing";

<CalmLensUploader
  uploadPresetId="test-preset"
  createClient={(options) =>
    new MockCalmLensAppClient({
      ...options,
      mockOptions: { queuedOutcome: "blocked", preset: { maxFileSizeInBytes: 5_000_000 } },
    })
  }
/>;
```

The [widget playground](https://github.com/CalmLens/sdks/tree/main/examples/widget-playground) runs every component in each library against these mocks, so you can try uploads, decisions and age-restricted media without a project.
