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, then add an endpoint that signs a token for each file:
const { token, expiresAt } = await client.uploadTokens.sign({
uploadPresetId: "your-upload-preset-id",
externalId: "post-42",
expiresInSeconds: 600,
});
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.
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}
/>
);
}
For each file, the widget:
- Refuses files that don't match
acceptor are larger thanmaxFileSizebefore 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 get their own message, and a failed upload can be retried.
- Calls
onUploadedwith{ assetId, status, file }unless the file was blocked or purged. Vue emitsuploaded, and the custom element dispatches acalmlens-uploadedevent with the samedetail.
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.
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.
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}
/>
);
}
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:
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"
/>
);
}
| 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 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.
widthsandsizesadd asrcset, so browsers fetch the smallest image that fits. Each width is a separate image transform; see Lenses and transforms. lensandtransformapply a named lens or a direct transform.aspectRatio(orwidthandheight) keeps the box its final size while the image loads.placeholderHashtakes the asset'scontent.image.thumbhashand draws the placeholder in the browser, with no placeholder request. It also works for private assets and signed URLs.urltakes 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.typeisimage(default),videooraudio. 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:
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 runs every component in each library against these mocks, so you can try uploads, decisions and age-restricted media without a project.