Features

Assets Package (BETA)

Centralized asset management with NuxtHub blob storage
Status: Experimental - The @friendlyinternet/nuxt-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.

Overview

Package Information

  • Package: @friendlyinternet/nuxt-crouton-assets
  • Version: 0.3.0 (BETA)
  • Type: Nuxt Layer / Addon Package
  • Repository: nuxt-crouton monorepo

What's Included

Components (2):

  • CroutonAssetsPicker - Browse and select assets from your media library
  • CroutonAssetsUploader - Upload files with metadata form

Composable (1):

  • useAssetUpload() - Programmatic asset upload handling

Integration:

  • NuxtHub blob storage configuration
  • Reference schema for collection generation
  • Auto-detection for asset reference fields

Key Features

  • 📸 Centralized Library - Single source of truth for all media uploads
  • 🎯 Visual Picker - Browse assets with thumbnail grid and search
  • 📊 Rich Metadata - Track filename, size, MIME type, alt text, timestamps
  • 👥 Team-Scoped - Assets automatically scoped to teams/organizations
  • 🔍 Search & Filter - Find assets by filename or alt text
  • ♿ Accessibility - Alt text support with i18n integration
  • 🔄 Reusable - Reference same asset across multiple collections
  • ⚡ Edge Storage - Powered by NuxtHub blob storage on Cloudflare

Installation

Prerequisites

Before installing, ensure you have:

  • Nuxt 4.0+
  • @friendlyinternet/nuxt-crouton installed
  • @nuxthub/core ^0.7.0 or higher
  • @vueuse/core ^11.0.0 or higher

Install Package

pnpm add @friendlyinternet/nuxt-crouton-assets

Configure Nuxt

Add the assets layer to your nuxt.config.ts:

export default defineNuxtConfig({
  extends: [
    '@friendlyinternet/nuxt-crouton',
    '@friendlyinternet/nuxt-crouton-assets'  // Add assets layer
  ],
  hub: {
    blob: true  // REQUIRED: Enable NuxtHub blob storage
  }
})
Important: NuxtHub blob storage (hub.blob: true) is required for the assets package to function.

Generate Assets Collection

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/@friendlyinternet/nuxt-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

Architecture

How It Works

The assets package follows a toolkit pattern - it provides reusable components and composables that work with your generated collection:

  1. Base Package (@friendlyinternet/nuxt-crouton)
    • Core upload infrastructure
    • POST /api/upload-image - Upload to blob
    • GET /images/[pathname] - Serve from blob
    • Basic upload components
  2. Assets Package (@friendlyinternet/nuxt-crouton-assets) - This Package
    • Reusable components and composables
    • CroutonAssetsPicker - Visual selector
    • CroutonAssetsUploader - Upload + metadata form
    • useAssetUpload() - Programmatic API
    • assets-schema.json - Reference schema
  3. Your Project (Generated Collection)
    • layers/core/collections/assets/
    • CRUD forms and API endpoints
    • Database tables and validation
    • Team-scoped asset management

Upload Flow

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

Database Schema

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
  alt: string             // Alt text for accessibility
  uploadedAt: Date        // Upload timestamp
  createdAt: Date         // Record created (auto)
  updatedAt: Date         // Record updated (auto)
  updatedBy: string       // Last modifier
}

Components

CroutonAssetsPicker

Browse and select assets from your media library.

Props

interface Props {
  collection?: string  // Collection name (default: 'assets')
}

// v-model
modelValue: string  // Selected asset ID

Features

  • Grid View: Thumbnail grid with 4-column layout
  • Search: Real-time filtering by filename or alt text
  • Upload: Inline upload button opens modal with uploader
  • Selection: Visual feedback with border and checkmark
  • Auto-refresh: Refreshes list after upload
  • Loading States: Skeleton loading for pending data

Usage

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"
    }
  }
}
Auto-Detection: If your 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>

Events

// Emitted when asset is selected
@update:modelValue: (assetId: string) => void

Component Source

Located at: packages/nuxt-crouton-assets/app/components/Picker.vue

Key Implementation Details:

  • Uses useFetch() to load assets from generated API
  • Filters assets client-side with computed property
  • Modal integration with CroutonAssetsUploader
  • Team ID from route params (useRoute().params.team)

CroutonAssetsUploader

Upload files with metadata form (alt text, filename display).

Props

interface Props {
  collection?: string  // Collection name (default: 'assets')
}

Events

@uploaded: (assetId: string) => void  // Emitted after successful upload

Features

  • File Selection: Uses CroutonImageUpload for file picker
  • Preview: Image preview before upload
  • Metadata Form: Alt text input field
  • File Info: Displays filename, size, MIME type
  • Upload State: Loading indicator during upload
  • Two-Step Process:
    1. Upload file to blob storage
    2. Create asset record in database

Usage

In 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>

Component Source

Located at: packages/nuxt-crouton-assets/app/components/Uploader.vue

Upload Process:

  1. User selects file via CroutonImageUpload
  2. File preview and metadata form appears
  3. User enters alt text (optional)
  4. Click "Upload Asset"
  5. File uploads to blob storage (POST /api/upload-image)
  6. Asset record created in database (POST /api/teams/[id]/assets)
  7. @uploaded event emitted with asset ID
  8. Form resets

Composable

useAssetUpload()

Programmatic asset upload handling for custom workflows.

API

const {
  uploadAsset,
  uploadAssets,
  uploading,
  error
} = useAssetUpload()

Returns

{
  // 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[]>

  // Reactive state
  uploading: Readonly<Ref<boolean>>
  error: Readonly<Ref<Error | null>>
}

Types

interface AssetMetadata {
  alt?: string
  filename?: string
}

interface UploadAssetResult {
  id: string
  pathname: string
  filename: string
  contentType: string
  size: number
  alt?: string
}

Usage Examples

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>

Error Handling

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>

NuxtHub Blob Storage

The assets package relies on NuxtHub's blob storage for file management.

Configuration

Required in nuxt.config.ts:

export default defineNuxtConfig({
  hub: {
    blob: true  // Enable blob storage
  }
})

How Blob Storage Works

Upload Endpoint (provided by base package):

// POST /api/upload-image
// Receives: FormData with 'image' field
// Returns: string (pathname)

// Example: "uploads/team-123/abc123.jpg"

Serving Route (provided by base package):

// GET /images/[pathname]
// Fetches from blob storage
// Serves file with correct content-type

File Organization

Files are stored with unique pathnames:

uploads/
├── team-123/
│   ├── abc123.jpg
│   ├── def456.png
│   └── ghi789.webp
└── team-456/
    ├── jkl012.jpg
    └── mno345.png

Benefits of Edge Storage

  • Global CDN: Fast delivery worldwide
  • Automatic Scaling: No storage limits
  • Cost Effective: Pay per usage
  • Cloudflare Integration: Seamless with NuxtHub
  • No Configuration: Works out of the box

Common Patterns

Product with Image

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>

Avatar Upload (Simple)

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-heroicons-x-mark" 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>

Reusable Assets Across Collections

// Same asset used in multiple places
{
  product1: { imageId: 'asset-abc123' },
  product2: { imageId: 'asset-abc123' },
  blogPost: { featuredImageId: 'asset-abc123' }
}

Benefits:

  • Saves storage space (file stored once)
  • Consistent images across application
  • Update alt text once, reflects everywhere
  • Centralized asset management

Best Practices

Alt Text Guidelines

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:

  • Improves accessibility for screen readers
  • Boosts SEO rankings
  • Makes search more effective
  • Helps content discovery

File Naming Conventions

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: ''
})

Team-Based Organization

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.

Search Optimization

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:

  • "summer" → Found via filename or alt
  • "beach" → Found via alt text
  • "sunset" → Found via both
  • "collection" → Found via filename

Troubleshooting

Assets Not Displaying in Picker

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

Upload Fails

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

Images Not Serving

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.

TypeScript Errors

Run typecheck after generation:

npx nuxt typecheck

Common issues:

  • Missing teamId in route params
  • Incorrect asset ID type (should be string)
  • Missing await on async operations

Migration Guide

From Direct URL Storage

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/@friendlyinternet/nuxt-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

API Reference

Component Props

CroutonAssetsPicker

{
  collection?: string      // Default: 'assets'
  modelValue?: string      // v-model (asset ID)
}

CroutonAssetsUploader

{
  collection?: string      // Default: 'assets'
}
// Events:
// @uploaded(assetId: string)

Composable Types

// useAssetUpload() return type
interface UseAssetUploadReturn {
  uploadAsset: (
    file: File,
    metadata?: AssetMetadata,
    collection?: string
  ) => Promise<UploadAssetResult>

  uploadAssets: (
    files: File[],
    metadata?: AssetMetadata,
    collection?: string
  ) => Promise<UploadAssetResult[]>

  uploading: Readonly<Ref<boolean>>
  error: Readonly<Ref<Error | null>>
}

interface AssetMetadata {
  alt?: string
  filename?: string
}

interface UploadAssetResult {
  id: string
  pathname: string
  filename: string
  contentType: string
  size: number
  alt?: string
}

Known Limitations (BETA)

Active Development: These limitations may be addressed in future releases.
  1. Single File Upload Only - The uploader currently handles one file at a time. Batch uploads require custom implementation with useAssetUpload().
  2. No Image Editing - No built-in cropping, resizing, or filters. Upload images pre-processed or add custom editing UI.
  3. No Automatic Thumbnails - Large images are served at full resolution. Consider generating thumbnails separately.
  4. Basic Search - Client-side filtering by filename and alt text only. For advanced search, implement server-side filtering.
  5. No Asset Deletion UI - Use the generated List.vue or implement custom delete functionality.
  6. Team Scoping Required - Assets must be scoped to teams. For global assets, create a "global" team or adjust the schema.

Roadmap (Future Versions)

Planned Features:

  • Multi-file upload support in uploader component
  • Image transformation and optimization
  • Automatic thumbnail generation
  • Folder/tag organization
  • Advanced search with filters
  • Asset usage tracking (where asset is referenced)
  • Bulk operations (delete, move, tag)
  • Image editing integration
  • Video/document support
  • Storage analytics

API Stability:

  • Current API will remain backward compatible
  • Major changes will follow semantic versioning
  • Migration guides provided for breaking changes

Feedback

This is a BETA package. Your feedback is valuable!

Version History

v0.3.0 (Current - BETA)

  • Initial beta release
  • CroutonAssetsPicker component
  • CroutonAssetsUploader component
  • useAssetUpload() composable
  • NuxtHub blob storage integration
  • Auto-detection for asset references
  • Reference schema for generation