Appearance
Download & Export ​
Overview ​
Collage Admin supports three download paths, each suited to a different use case:
- Single-asset proxied download — the Nitro server fetches the S3 URL and streams it back with a correct
Content-Dispositionheader, keeping the S3 bucket URL private from the browser. - Presigned URL download —
server/api/s3/get-signed-url.get.tsgenerates a short-lived (24-hour) S3 presigned URL that the frontend uses directly. Requires a valid Bearer token. - Bulk zip download — multiple assets are assembled into a ZIP archive in the browser using the StreamSaver library, streamed directly to the filesystem via a write-stream with no server-side zip step and no memory limit.
Format and resolution conversion are available at download time as a plan-gated feature. The FileConversionDialog presents format and resolution selectors; on confirm the Nitro resolution.post.ts route resizes and re-encodes the image using Jimp and returns the transformed buffer.
Architecture ​
StreamSaver is registered as a client-only Nuxt plugin (stream-saver.client.ts). It is exposed on nuxtApp as $streamSaver and proxied so that calling createWriteStream on auth pages throws rather than silently failing. The actual zip-stream work is driven by the DAM store's downloadMultipleFiles action, which calls useZipDownloadApi.fetchZipStream() to POST the asset list to an external zip-worker microservice (ZIP_DOWNLOAD_URL runtime config), then pipes the response body into a StreamSaver write-stream.
Image conversion runs entirely server-side in the Nitro process. The resolution.post.ts handler fetches the source image itself (with a 30-second timeout and a 200 MB size cap), reads PNG/GIF/JPEG headers to reject oversized sources before decoding, uses Jimp to resize and re-encode, and streams the output buffer back. Callers on the frontend side go through useFileConversionDialog, which computes the available format options and resolution list from the asset detail and subscription plan.
File Structure ​
Plugin Files ​
app/plugins/stream-saver.client.ts— StreamSaver plugin- Client-only; skipped during SSR
- Provides
$streamSaveronnuxtApp - Proxies
createWriteStreamto block calls on auth pages (index,forgot-password,reset-password,social-login,generate-password) - Reads
route.namereactively at call time to avoid freezing the auth-page decision at plugin boot
Composable Files ​
app/composables/api/useZipDownloadApi.ts— zip download API composablefetchZipStream(payload)— POSTs to external zip-worker, returns raw streamingResponse- Uses native
fetch(not$api) intentionally — callers need the raw body for streaming - Fails loud when
ZIP_DOWNLOAD_URLis not configured - Request state tracked via
useApiRequestState
app/composables/core/dialogs/useFileConversionDialog.ts— conversion dialog composable- Derives
downloadableFormatOptionsfromfile.mime_typeand plan entitlements - Derives
otherResolutionsfrom asset dimensions - Computes
selectedFormat,selectedResolution,actionDisabled,btnTxt - Exposes
handleAction,closeDialog
- Derives
Component Files ​
app/components/dialogs/FileConversionDialog.vue— format and resolution picker dialog- Format selector (
v-select) — disabled and tooltipped for non-Pro plans - Resolution selector (
v-select) — disabled when mime type is not resizable - Download / Convert button with loading state
- Emits
download-org,convert-resolution,convert-formatdepending on selection
- Format selector (
Server Route Files ​
server/api/s3/download-file.get.ts— proxied single-file download- Query params:
url(encoded S3/presigned URL),name(base filename) - Fetches source URL server-side, detects content-type, maps to extension
- Sets
Content-Disposition: attachmentand streams the body back to the browser - Supported extensions: png, jpg, webp, gif, svg, pdf, txt, json, zip
- Query params:
server/api/s3/get-signed-url.get.ts— S3 presigned URL generator- Query params:
assetId,workspaceId - Requires Bearer token; validates
workspaceIdformat andassetIdagainst[A-Za-z0-9_-]+ - Generates
{workspaceId}/digital_assets/{assetId}key - Presigned URL expires in 86400 seconds (24 hours)
- Query params:
server/api/resolution.post.ts— image resize and format conversion- Request body:
{ w, h, s, q, m? }(width, height, source URL or base64, quality, target mime) - Fetches source with 30-second timeout and 200 MB streaming cap
- Reads PNG/GIF/JPEG headers to reject images over 600 megapixels before decoding
- Resizes with Jimp (
image.resize({ w, h })), encodes with per-format quality - Supported output:
image/jpeg,image/png,image/bmp,image/tiff,image/gif - Returns raw image buffer with correct
Content-Typeheader
- Request body:
Page Files ​
app/pages/[workspace_id]/dam/sharing/index.vue— share and embed management page; two tabs (Shares / Embeds), filter controls, bulk revoke/delete, and per-row action menus
Page Details ​
Sharing ([workspace_id]/dam/sharing/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/sharing |
| Layout | collage-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended |
| Primary composable | useSharing() |
Tabs
| Tab | Columns | Notes |
|---|---|---|
| Shares | Title, Description, Created Date, 30-day views, Created By | Filter v-select: active / revoked / expired |
| Embeds | Filename, Embed URL, Date, Created By | No status filter |
Bulk-action bar (DamBulkActionsBar) — hidden for viewer-role users
| Action | Gate |
|---|---|
| Revoke | non-viewer only |
| Delete | non-viewer only |
Marquee drag-selection is also disabled for viewers. Per-row context menus differ between active shares (Copy Link, Edit, Revoke, Delete) and revoked/expired shares (Copy Link, Delete).
Dialogs: ConfirmationDialog, AdvanceShareDialog
FileConversionDialog ​
File: app/components/dialogs/FileConversionDialog.vue
Modal dialog for selecting download format and resolution. Plan-gating is surfaced inline via tooltip text directing non-Pro users to upgrade.
Props ​
typescript
interface Props {
dialog: boolean // controls v-dialog visibility
downloadableFormats: string[] // list of available format strings (e.g. ['jpeg', 'png', 'webp'])
otherResolutions: ResolutionOption[] // resolution options from asset dimensions
file: AssetDetail // full asset detail object
assetConversionAllowed: boolean // plan gate — false disables format selector
downloading?: boolean // shows spinner on Download button
converting?: boolean // shows spinner on Convert button
}Events ​
typescript
{
'update:dialog': [value: boolean] // v-model passthrough for dialog open/close
'close': [] // dialog closed by X button
'download-org': [] // original file download (no conversion)
'convert-resolution': [payload: { width: number; height: number; quality: number }]
'convert-format': [format: string] // convert to selected format
}Usage Example ​
vue
<template>
<FileConversionDialog
v-model:dialog="showDialog"
:downloadable-formats="['jpeg', 'png', 'webp']"
:other-resolutions="resolutions"
:file="assetDetail"
:asset-conversion-allowed="plan.canConvert"
:downloading="isDownloading"
:converting="isConverting"
@download-org="downloadOriginal"
@convert-format="handleFormatConvert"
@convert-resolution="handleResolutionConvert"
/>
</template>
<script setup lang="ts">
import type { AssetDetail, ResolutionOption } from '~/types/asset-detail'
const showDialog = ref(false)
const isDownloading = ref(false)
const isConverting = ref(false)
async function downloadOriginal() {
isDownloading.value = true
// trigger presigned URL download
isDownloading.value = false
}
async function handleFormatConvert(format: string) {
isConverting.value = true
// call resolution.post.ts with target format
isConverting.value = false
}
async function handleResolutionConvert(payload: { width: number; height: number; quality: number }) {
isConverting.value = true
// call resolution.post.ts with target dimensions
isConverting.value = false
}
</script>useZipDownloadApi ​
File: app/composables/api/useZipDownloadApi.ts
Posts an asset list to the external zip-worker service and returns the raw streaming Response for piping into StreamSaver.
Methods ​
typescript
{
fetchZipStream: (payload: unknown) => Promise<Response>
isLoading: Ref<boolean>
error: Ref<unknown>
}Usage Example ​
vue
<script setup lang="ts">
const { fetchZipStream } = useZipDownloadApi()
const { $streamSaver } = useNuxtApp()
async function downloadAsZip(assetIds: string[]) {
const response = await fetchZipStream({ assetIds })
const fileStream = $streamSaver.createWriteStream('assets.zip')
await response.body!.pipeTo(fileStream)
}
</script>Workflows ​
Single Asset Download ​
User clicks "Download" on AssetCard or AssetDetailPanel
→ fetch presigned URL
GET /api/s3/get-signed-url?assetId=...&workspaceId=...
→ returns short-lived S3 presigned URL
→ trigger browser download
GET /api/s3/download-file?url=<encoded>&name=<filename>
→ Nitro fetches source, detects MIME, sets Content-Disposition
→ streams body to browser
→ Amplitude: asset downloadedBulk Zip Download ​
User selects multiple assets → AssetBulkActionBar → "Download"
→ DAM store downloadMultipleFiles action
→ useZipDownloadApi.fetchZipStream({ assetIds })
POST {ZIP_DOWNLOAD_URL}download
→ returns streaming Response (zip body)
→ $streamSaver.createWriteStream('assets.zip')
→ response.body.pipeTo(fileStream)
[zip assembled in browser, written directly to filesystem]
→ Amplitude: assets bulk-downloadedFormat / Resolution Conversion ​
User clicks "Download" on a Pro plan workspace
→ FileConversionDialog opens
↓ format-only download (original resolution)
emit 'download-org'
→ GET /api/s3/download-file?url=...&name=...
↓ resolution conversion
emit 'convert-resolution' { width, height, quality }
→ POST /api/resolution { w, h, s: presignedUrl, q: quality }
→ Nitro fetches source image (30s timeout, 200MB cap)
→ readHeader() — reject if > 600MP
→ Jimp.fromBuffer() → image.resize({ w, h })
→ image.getBuffer(mime, { quality })
→ returns image buffer
→ browser triggers download of converted buffer
↓ format conversion
emit 'convert-format' 'png'
→ POST /api/resolution { w, h, s: presignedUrl, q: 90, m: 'image/png' }
→ same pipeline, output mime = image/png
→ Amplitude: asset format-convertedAPI Integration ​
Server Routes ​
| Route | Method | Params | Description |
|---|---|---|---|
/api/s3/download-file | GET | url, name | Proxy S3 URL, set filename |
/api/s3/get-signed-url | GET | assetId, workspaceId | Generate 24hr presigned URL |
/api/resolution | POST | body { w, h, s, q, m? } | Resize / re-encode image |
Presigned URL Request / Response ​
json
// GET /api/s3/get-signed-url?assetId=abc123&workspaceId=7
// Authorization: Bearer eyJ...
// Response — raw URL string
"https://bucket.s3.amazonaws.com/7/digital_assets/abc123?X-Amz-Signature=..."Resolution Conversion Request / Response ​
json
// POST /api/resolution
{
"w": 1920,
"h": 1080,
"s": "https://bucket.s3.amazonaws.com/7/digital_assets/abc123?...",
"q": 85,
"m": "image/png"
}
// Response — raw image buffer (Content-Type: image/png)
<binary buffer>Zip Worker Request / Response ​
json
// POST {ZIP_DOWNLOAD_URL}download
{
"payload": {
"assetIds": ["abc123", "def456"],
"workspaceId": 7
}
}
// Response — streaming zip body (Content-Type: application/zip)
<streaming body>Component Integration ​
Download and format conversion are wired together in app/pages/[workspace_id]/dam/files/[_id]/index.vue. All download state and actions come from useAssetDetail() — there is no intermediate wrapper composable at the page level.
The download icon calls downloadAndConvertFile(), which opens FileConversionDialog when the asset is on a Pro plan and conversion is available, or triggers a direct download otherwise. FileConversionDialog emits back to the page, and the page adapts the convert-resolution payload shape before forwarding it to the composable.
vue
<!-- Download icon in title bar -->
<v-btn
v-if="canShareDownloadAsset"
icon
size="large"
variant="text"
:disabled="downloading"
@click="downloadAndConvertFile()"
>
<v-progress-circular v-if="downloading" indeterminate size="14" width="2" />
<AsyncIcon v-else name="downloadIcon" />
</v-btn>
<!-- FileConversionDialog at bottom of template -->
<FileConversionDialog
v-if="file?.id"
v-model:dialog="fileConversionDialog"
:downloadable-formats="downloadableFormats"
:other-resolutions="otherResolutions"
:file="file"
:asset-conversion-allowed="assetConversionAllowed"
:downloading="downloadingWithResolution"
:converting="converting"
@convert-resolution="handleConvertResolution"
@download-org="downloadFile"
@convert-format="convertFormat"
@close="fileConversionDialog = false"
/>vue
<script setup lang="ts">
const {
file,
downloading,
downloadingWithResolution,
converting,
fileConversionDialog,
downloadableFormats,
otherResolutions,
assetConversionAllowed,
canShareDownloadAsset,
downloadAndConvertFile,
downloadFile,
convertFormat,
convertResolution,
} = useAssetDetail()
// FileConversionDialog emits a single payload object;
// convertResolution takes positional args — adapt here rather than in the composable.
const handleConvertResolution = (payload: { width: number; height: number; quality: number }) => {
convertResolution(payload.width, payload.height, payload.quality)
}
</script>Related Documentation ​
- Asset Management — Upload Flow
- Subscription & Plans — plan gating for format conversion
- Server API — S3 Routes
- Plugins — StreamSaver