Assets & Images
Hoikka stores uploaded images behind a storage seam and serves them through a route that resizes on demand. The same /uploads/... URLs work on both deployment targets.
Storage Seam
src/lib/server/storage/{index,fs,r2}.ts mirror the db seam: when the R2 binding (ASSETS_BUCKET) is present on the request (Cloudflare target) it is used; otherwise files go to the local filesystem under data/uploads (node target).
export const upload = /* → { url } */;
export const list = /* ... */;
export const remove = /* ... */;
export const get = /* → { body, contentType, size } | null */;Serving & Resizing
/uploads/[...path] serves the file from the active backend and resizes it on demand via ?w=<width>&q=<quality>:
- Node target —
sharpwith a disk cache (src/lib/server/images/node.ts). - Cloudflare target — the Cloudflare Images binding (
IMAGES), falling back to the original when the binding isn’t configured, plus edge caching of transformed variants.
/uploads/products/shirt-abc123.jpg?w=400&q=80Width is clamped to 16–2400, quality to 30–95 (default 80). Responses are immutable (filenames carry a random id).
The <Img> Component
Prefer src/lib/components/storefront/Img.svelte over raw <img> — it emits src plus a 1x/2x srcset, lazy-loads by default, and positions the crop on the asset’s focal point.
<script>
import Img from "$lib/components/storefront/Img.svelte";
let { asset, name } = $props();
</script>
<Img
src={asset.source}
alt={name}
width={400}
sizes="(max-width: 640px) 50vw, 400px"
focalX={asset.focalX}
focalY={asset.focalY}
class="h-full w-full object-cover"
/>Helpers
imageUrl(source, width?, quality?); // appends ?w=&q= for /uploads URLs; passes external URLs through
imageSrcset(source, width, quality?); // "url 1x, url@2x 2x" for /uploads; undefined for external
focalPosition(focalX, focalY); // CSS object-position from 0–1 coordsOnly /uploads/... sources are transformed; external URLs are returned untouched.
Uploading
Uploads go through POST /api/assets/upload (src/routes/api/assets/upload/+server.ts), restricted to admin/staff and blocked in demo mode. It accepts JPEG, PNG, GIF, WebP, SVG, or AVIF up to 10 MB, writes to the storage backend via upload(), and returns the /uploads/... URL plus metadata.
1. Admin ImagePicker selects a file
2. Client POSTs it to /api/assets/upload
3. Server writes it to the active backend (fs or R2)
4. Server returns { url, name, width, height, size }
5. Client saves the asset record to the databaseDatabase Schema
Assets are stored in the assets table:
assets
├── id
├── name // file name
├── type // image | video | document | other
├── mimeType // image/jpeg, image/png, ...
├── width, height, fileSize
├── source // /uploads/... URL
├── alt // alt text
├── focalX // 0–1 horizontal focal point (default 0.5)
├── focalY // 0–1 vertical focal point (default 0.5)
└── createdAtProducts reference assets by featuredAssetId:
products.featuredAssetId → assets.id
product_variants.featuredAssetId → assets.id