@fyit/crouton-assets package is in active development. APIs may change before the stable release. Use with caution in production.The assets package extends Nuxt Crouton with a centralized media library system, providing full-featured asset management with team-based ownership, rich metadata tracking, and NuxtHub blob storage integration.
@fyit/crouton-assetsComponents (7):
CroutonAssetsPicker - Browse and select assets with type filtering (images, documents, video, audio)CroutonAssetsUploader - Upload files with optional crop step and metadata formCroutonAssetsLibrary - Full asset library view with grid/list layoutCroutonAssetsCard - Asset preview card for list/grid displayCroutonAssetsAssetTile - Compact asset tile with selection supportCroutonAssetsForm - Asset creation form with crop and metadataCroutonAssetsFormUpdate - Asset metadata update form (alt text, translations)Composable (1):
useAssetUpload() - Programmatic asset upload/delete with progress trackingIntegration:
image and file first-class CLI field typesBefore installing, ensure you have:
@fyit/crouton-core installed (includes NuxtHub blob support)@vueuse/core ^11.0.0 or higherpnpm add @fyit/crouton-assets
Add the assets layer to your nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'@fyit/crouton',
'@fyit/crouton-assets' // Add assets layer
],
hub: {
blob: true // REQUIRED: Enable NuxtHub blob storage
}
})
hub.blob: true) is required for the assets package to function.The package provides tools (components and composables), but you need to generate the actual collection in your project:
crouton-generate core assets \
--fields-file=node_modules/@fyit/crouton-assets/assets-schema.json \
--dialect=sqlite
This creates:
layers/core/collections/assets/
├── Form.vue # CRUD form with asset metadata
├── List.vue # Asset library list view
├── CardMini.vue # Asset preview card
├── index.ts # Exports
├── schema.ts # Zod validation
├── drizzle.ts # Database schema
└── api/
└── [...].ts # CRUD endpoints
The assets package follows a toolkit pattern - it provides reusable components and composables that work with your generated collection:
@fyit/crouton)POST /api/upload-image - Upload to blobGET /images/[pathname] - Serve from blob@fyit/crouton-assets) - This PackageCroutonAssetsPicker - Visual selectorCroutonAssetsUploader - Upload + metadata formuseAssetUpload() - Programmatic APIassets-schema.json - Reference schemalayers/core/collections/assets/1. User selects file in CroutonAssetsUploader
↓
2. File uploaded to NuxtHub blob storage
→ POST /api/upload-image
→ Returns pathname (e.g., "uploads/abc123.jpg")
↓
3. Asset record created in database
→ POST /api/teams/[teamId]/assets
→ Stores: filename, pathname, contentType, size, alt, etc.
↓
4. Asset now available in media library
→ GET /api/teams/[teamId]/assets
→ CroutonAssetsPicker displays it
The generated assets collection includes these fields:
{
id: string // Unique identifier (primaryKey)
teamId: string // Team/organization ownership (required)
userId: string // User who uploaded (required)
filename: string // Original filename (required)
pathname: string // Blob storage path (required)
contentType: string // MIME type (image/jpeg, etc)
size: number // File size in bytes
category: string // 'image' | 'video' | 'audio' | 'document' | 'other'
width: number // Image width in px (0 for non-images)
height: number // Image height in px (0 for non-images)
alt: string // Alt text for accessibility
uploadedAt: Date // Upload timestamp
createdAt: Date // Record created (auto)
updatedAt: Date // Record updated (auto)
updatedBy: string // Last modifier
}
Browse and select assets from your media library.
interface Props {
collection?: string // Collection name (default: 'crouton-assets')
crop?: boolean | { aspectRatio?: number } // Enable image cropping
}
// v-model
modelValue: string // Selected asset ID
In Forms (Schema Definition)
The easiest way is to reference assets in your collection schema:
{
"imageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"component": "CroutonAssetsPicker",
"label": "Featured Image",
"area": "main"
}
}
}
refTarget points to assets, images, files, or media, the generator automatically uses CroutonAssetsPicker - no need to specify the component!Direct Usage
<template>
<UFormField label="Product Image" name="imageId">
<CroutonAssetsPicker v-model="state.imageId" />
</UFormField>
</template>
<script setup lang="ts">
const state = ref({
imageId: ''
})
// Access selected asset ID
watch(() => state.imageId, (newId) => {
console.log('Selected asset:', newId)
})
</script>
Custom Collection
<template>
<CroutonAssetsPicker
v-model="selectedId"
collection="productImages"
/>
</template>
<script setup lang="ts">
const selectedId = ref('')
</script>
// Emitted when asset is selected (v-model update)
@update:modelValue: (assetId: string) => void
// Emitted when asset is confirmed with full asset data
@select: (asset: Record<string, any>) => void
Located at: packages/crouton-assets/app/components/Picker.vue
Key Implementation Details:
useFetch() to load assets from generated APICroutonAssetsUploaderuseRoute().params.team)Upload files with metadata form (alt text, filename display).
interface Props {
collection?: string // Collection name (default: 'crouton-assets')
crop?: boolean | { aspectRatio?: number | AspectRatioPreset } // Enable image cropping
}
type AspectRatioPreset = 'free' | '1:1' | '16:9' | '4:3' | '3:2'
@uploaded: (assetId: string) => void // Emitted after successful upload
CroutonImageUpload for file pickerIn Modal
<template>
<div>
<UButton @click="showUploader = true">
Upload New Asset
</UButton>
<UModal v-model="showUploader">
<template #content="{ close }">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Upload New Asset</h3>
<CroutonAssetsUploader @uploaded="handleUploaded(close)" />
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
const showUploader = ref(false)
const handleUploaded = async (close: () => void, assetId: string) => {
console.log('Uploaded asset ID:', assetId)
// Optionally refresh your asset list
close()
showUploader.value = false
}
</script>
Standalone
<template>
<div class="max-w-md mx-auto">
<CroutonAssetsUploader @uploaded="onAssetUploaded" />
</div>
</template>
<script setup lang="ts">
const onAssetUploaded = (assetId: string) => {
console.log('New asset:', assetId)
// Navigate to asset or show success message
navigateTo(`/assets/${assetId}`)
}
</script>
Located at: packages/crouton-assets/app/components/Uploader.vue
Upload Process:
CroutonImageUploadPOST /api/upload-image)POST /api/teams/[id]/assets)@uploaded event emitted with asset IDProgrammatic asset upload handling for custom workflows.
const {
uploadAsset,
uploadAssets,
uploading,
error
} = useAssetUpload()
{
// Upload single asset
uploadAsset: (
file: File,
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult>
// Upload multiple assets in parallel
uploadAssets: (
files: File[],
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult[]>
// Delete an asset's blob file
deleteAssetFile: (pathname: string) => Promise<void>
// Reactive state
uploading: Readonly<Ref<boolean>>
error: Readonly<Ref<Error | null>>
progress: Readonly<Ref<number>> // 0-100
}
interface AssetMetadata {
alt?: string
filename?: string
translations?: Record<string, { alt?: string }>
}
interface UploadAssetResult {
id: string
pathname: string
filename: string
contentType: string
size: number
alt?: string
}
Simple Upload
<script setup lang="ts">
const { uploadAsset, uploading, error } = useAssetUpload()
const handleFileInput = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const asset = await uploadAsset(file, {
alt: 'User uploaded image',
filename: file.name
})
console.log('Upload successful:', asset.id)
} catch (err) {
console.error('Upload failed:', error.value)
}
}
</script>
<template>
<div>
<input type="file" @change="handleFileInput" :disabled="uploading" />
<p v-if="uploading">Uploading...</p>
<p v-if="error" class="text-red-500">{{ error.message }}</p>
</div>
</template>
Drag-and-Drop Upload
<script setup lang="ts">
const { uploadAsset, uploading } = useAssetUpload()
const handleDrop = async (event: DragEvent) => {
event.preventDefault()
const file = event.dataTransfer?.files[0]
if (!file) return
const asset = await uploadAsset(file, {
alt: 'Drag-and-drop upload'
})
console.log('Dropped file uploaded:', asset.id)
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
</script>
<template>
<div
@drop="handleDrop"
@dragover="handleDragOver"
class="border-2 border-dashed rounded-lg p-12 text-center"
:class="uploading ? 'opacity-50' : 'hover:border-primary-500'"
>
<p v-if="!uploading">Drop files here to upload</p>
<p v-else>Uploading...</p>
</div>
</template>
Batch Upload
<script setup lang="ts">
const { uploadAssets, uploading } = useAssetUpload()
const handleMultipleFiles = async (event: Event) => {
const files = Array.from((event.target as HTMLInputElement).files || [])
if (files.length === 0) return
try {
const assets = await uploadAssets(files, {
alt: 'Batch uploaded images'
})
console.log(`Uploaded ${assets.length} assets`)
assets.forEach(asset => {
console.log(`- ${asset.filename}: ${asset.id}`)
})
} catch (err) {
console.error('Batch upload failed')
}
}
</script>
<template>
<div>
<input
type="file"
multiple
@change="handleMultipleFiles"
:disabled="uploading"
/>
<p v-if="uploading">Uploading multiple files...</p>
</div>
</template>
Custom Collection
<script setup lang="ts">
const { uploadAsset } = useAssetUpload()
const uploadProductImage = async (file: File) => {
// Upload to custom collection
const asset = await uploadAsset(
file,
{ alt: 'Product image' },
'productImages' // Custom collection
)
return asset.id
}
</script>
The composable catches errors and stores them in the error ref:
<script setup lang="ts">
const { uploadAsset, error } = useAssetUpload()
const upload = async (file: File) => {
try {
await uploadAsset(file)
} catch (err) {
// error.value is also set
console.error('Upload failed:', error.value?.message)
// Show toast notification
useToast().add({
title: 'Upload Failed',
description: error.value?.message || 'Unknown error',
color: 'red'
})
}
}
</script>
The assets package relies on NuxtHub's blob storage for file management.
Required in nuxt.config.ts:
export default defineNuxtConfig({
hub: {
blob: true // Enable blob storage
}
})
Upload Endpoint (provided by base package):
// POST /api/upload-image (authenticated)
// Receives: FormData with 'file' (or 'image') field
// Returns: { pathname, contentType, size, filename }
// Supports: images, PDFs, video, audio, documents
// Configurable via runtimeConfig.public.croutonUpload
Delete Endpoint (provided by base package):
// DELETE /api/upload-image (authenticated)
// Receives: { pathname: string }
// Returns: { success: true, pathname }
Serving Route (provided by base package):
// GET /images/[pathname]
// Fetches from blob storage
// Serves file with correct content-type
// Cache headers: public, max-age=31536000, immutable
Files are stored with unique pathnames:
uploads/
├── team-123/
│ ├── abc123.jpg
│ ├── def456.png
│ └── ghi789.webp
└── team-456/
├── jkl012.jpg
└── mno345.png
Schema Definition:
{
"name": {
"type": "string",
"meta": { "required": true, "area": "main" }
},
"description": {
"type": "string",
"meta": { "component": "Textarea", "area": "main" }
},
"imageId": {
"type": "string",
"refTarget": "assets",
"meta": {
"label": "Product Image",
"area": "sidebar"
}
},
"price": {
"type": "number",
"meta": { "required": true, "area": "sidebar" }
}
}
Generated Form: Automatically includes asset picker thanks to auto-detection!
Display Product with Image:
<script setup lang="ts">
const route = useRoute()
const teamId = route.params.team as string
const productId = route.params.id as string
// Fetch product
const { data: product } = await useFetch(
`/api/teams/${teamId}/products/${productId}`
)
// Fetch referenced asset
const { data: asset } = await useFetch(
() => product.value?.imageId
? `/api/teams/${teamId}/assets/${product.value.imageId}`
: null,
{ watch: [() => product.value?.imageId] }
)
const imageUrl = computed(() =>
asset.value?.pathname ? `/images/${asset.value.pathname}` : '/placeholder.png'
)
</script>
<template>
<div v-if="product">
<img
:src="imageUrl"
:alt="asset?.alt || product.name"
class="w-full h-64 object-cover rounded-lg"
/>
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
<p class="text-2xl font-bold">${{ product.price }}</p>
</div>
</template>
For user avatars, you might prefer the simple approach without the asset library:
<template>
<CroutonAvatarUpload
v-model="avatarUrl"
@file-selected="handleUpload"
/>
</template>
<script setup lang="ts">
const user = useCurrentUser()
const avatarUrl = ref(user.value?.avatar || '/default-avatar.png')
const handleUpload = async (file: File | null) => {
if (!file) return
const formData = new FormData()
formData.append('image', file)
const pathname = await $fetch('/api/upload-image', {
method: 'POST',
body: formData
})
avatarUrl.value = `/images/${pathname}`
// Update user profile
await $fetch(`/api/users/${user.value.id}`, {
method: 'PATCH',
body: { avatar: pathname }
})
}
</script>
Schema with Multiple Assets:
{
"title": { "type": "string", "meta": { "required": true } },
"featuredImageId": {
"type": "string",
"refTarget": "assets",
"meta": { "label": "Featured Image" }
},
"galleryImageIds": {
"type": "json",
"meta": {
"label": "Gallery Images",
"component": "MultiAssetPicker"
}
}
}
Custom Multi-Picker Component:
<!-- components/MultiAssetPicker.vue -->
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<div
v-for="(assetId, index) in selectedIds"
:key="assetId"
class="relative group"
>
<img
:src="`/images/${getAssetPathname(assetId)}`"
class="w-24 h-24 object-cover rounded"
/>
<button
@click="removeAsset(index)"
class="absolute -top-2 -right-2 bg-red-500 rounded-full p-1"
>
<UIcon name="i-lucide-x" class="w-4 h-4 text-white" />
</button>
</div>
</div>
<UButton @click="showPicker = true">
Add Images
</UButton>
<UModal v-model="showPicker">
<template #content="{ close }">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Select Images</h3>
<CroutonAssetsPicker v-model="tempSelection" />
<div class="flex justify-end gap-2 mt-4">
<UButton variant="ghost" @click="close">Cancel</UButton>
<UButton @click="addAsset(close)">Add</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
const selectedIds = defineModel<string[]>({ default: () => [] })
const showPicker = ref(false)
const tempSelection = ref('')
const addAsset = (close: () => void) => {
if (tempSelection.value && !selectedIds.value.includes(tempSelection.value)) {
selectedIds.value.push(tempSelection.value)
}
tempSelection.value = ''
close()
}
const removeAsset = (index: number) => {
selectedIds.value.splice(index, 1)
}
const getAssetPathname = (assetId: string) => {
// Fetch pathname from asset - implement caching
return assetId
}
</script>
// Same asset used in multiple places
{
product1: { imageId: 'asset-abc123' },
product2: { imageId: 'asset-abc123' },
blogPost: { featuredImageId: 'asset-abc123' }
}
Benefits:
Always provide descriptive alt text for accessibility and SEO:
Good Alt Text:
await uploadAsset(file, {
alt: 'Red Nike Air Max 90 sneakers on white background, side view'
})
Bad Alt Text:
await uploadAsset(file, {
alt: 'image' // Too generic
})
await uploadAsset(file, {
alt: '' // Missing
})
Benefits of Good Alt Text:
Use descriptive, consistent filenames:
// Good
await uploadAsset(file, {
filename: 'red-nike-air-max-90-side-view.jpg',
alt: 'Red Nike Air Max 90, side view'
})
// Avoid
await uploadAsset(file, {
filename: 'IMG_1234.jpg', // Not descriptive
alt: ''
})
Assets are automatically scoped to teams via teamId:
// Team A's assets
GET /api/teams/team-123/assets
// Returns only team-123's assets
// Team B's assets
GET /api/teams/team-456/assets
// Returns only team-456's assets
No manual filtering needed - the generated API handles team scoping.
Make assets easy to find:
await uploadAsset(file, {
filename: 'summer-collection-beach-sunset.jpg',
alt: 'Summer collection photo shoot at beach during golden hour sunset'
})
Search will match:
Check NuxtHub blob storage is enabled:
// nuxt.config.ts
export default defineNuxtConfig({
hub: {
blob: true // Must be true
}
})
Verify assets collection exists:
ls layers/core/collections/assets
# Should show: _Form.vue, List.vue, drizzle.ts, etc.
Check team ID in route:
The picker needs team in route params:
// Route must include team
/teams/:team/products
/teams/:team/blog-posts
Check file size limits:
NuxtHub blob storage has default limits. For large files, configure limits.
Verify blob storage permissions:
Ensure your NuxtHub project has blob storage enabled in the dashboard.
Review server logs:
# Check for errors
pnpm dev
# Look for upload errors in console
Verify pathname is correct:
console.log('Pathname:', asset.pathname)
console.log('Full URL:', `/images/${asset.pathname}`)
Test serving route directly:
curl http://localhost:3000/images/uploads/team-123/abc123.jpg
Check blob storage:
In NuxtHub dashboard, verify the file exists in blob storage.
Run typecheck after generation:
npx nuxt typecheck
Common issues:
teamId in route paramsstring)If you're currently storing image URLs directly in your database:
1. Update Schema
{
"name": { "type": "string" },
- "imageUrl": { "type": "string" }
+ "imageId": {
+ "type": "string",
+ "refTarget": "assets"
+ }
}
2. Generate Assets Collection
crouton-generate core assets \
--fields-file=node_modules/@fyit/crouton-assets/assets-schema.json
3. Migrate Existing Data
Create migration script:
// scripts/migrate-to-assets.ts
import { db } from '~/server/db'
import { products, assets } from '~/server/db/schema'
const allProducts = await db.select().from(products)
for (const product of allProducts) {
if (!product.imageUrl) continue
// Extract pathname from URL
const pathname = product.imageUrl.replace('/images/', '')
// Create asset record
const [asset] = await db.insert(assets).values({
id: generateId(),
teamId: product.teamId,
userId: product.userId || 'system',
filename: pathname.split('/').pop() || 'unknown.jpg',
pathname,
contentType: 'image/jpeg', // Detect from file extension
size: 0, // Could fetch from blob
uploadedAt: product.createdAt,
alt: product.name // Use product name as fallback alt
}).returning()
// Update product reference
await db
.update(products)
.set({ imageId: asset.id })
.where(eq(products.id, product.id))
}
console.log(`Migrated ${allProducts.length} products`)
4. Regenerate Collection
crouton-generate products shopProducts --overwrite
CroutonAssetsPicker
{
collection?: string // Default: 'crouton-assets'
modelValue?: string // v-model (asset ID)
crop?: boolean | { aspectRatio?: number } // Enable image cropping
}
// Events:
// @update:modelValue(assetId: string)
// @select(asset: Record<string, any>)
CroutonAssetsUploader
{
collection?: string // Default: 'crouton-assets'
crop?: boolean | { aspectRatio?: number | AspectRatioPreset } // Enable image cropping
}
// Events:
// @uploaded(assetId: string)
// useAssetUpload() return type
interface UseAssetUploadReturn {
uploadAsset: (
file: File,
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult>
uploadAssets: (
files: File[],
metadata?: AssetMetadata,
collection?: string
) => Promise<UploadAssetResult[]>
deleteAssetFile: (pathname: string) => Promise<void>
uploading: Readonly<Ref<boolean>>
error: Readonly<Ref<Error | null>>
progress: Readonly<Ref<number>> // 0-100
}
interface AssetMetadata {
alt?: string
filename?: string
translations?: Record<string, { alt?: string }>
}
interface UploadAssetResult {
id: string
pathname: string
filename: string
contentType: string
size: number
alt?: string
}
Planned Features:
API Stability:
This is a BETA package. Your feedback is valuable!
nuxt-crouton-assetsv0.1.0 (Current - BETA)