Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 63 additions & 21 deletions apps/docs/content/docs/dev/events/built-in-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ to any of them from your own plugin with
[`buildEventListener`](/docs/dev/events#listening-to-an-event) - no imports from
the emitting plugin are needed, the event map is global.

| Event | Payload | Emitted when |
| ----------------------- | ---------------------------------------- | ------------------------------------------------------- |
| `user.created` | `{ userId, email, name, emailVerified }` | A user is created - sign-up, AdminCP, or SSO first sign-in |
| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile or roles) |
| `user.deleted` | `{ userId, email }` | _Declared only_ - core has no user deletion flow yet |
| `role.created` | `{ roleId }` | A role is created in the AdminCP |
| `role.updated` | `{ roleId }` | A role is edited in the AdminCP |
| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP |
| `blog.post.created` | `{ postId, categoryId }` | A blog post is created |
| `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited |
| `blog.post.deleted` | `{ postId }` | A blog post is deleted |
| `blog.category.created` | `{ categoryId }` | A blog category is created |
| `blog.category.updated` | `{ categoryId }` | A blog category is edited |
| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category is deleted (`postIds` is always empty) |
| Event | Payload | Emitted when |
| ----------------------- | -------------------------------------------------- | ---------------------------------------------------------- |
| `user.created` | `{ userId, email, name, emailVerified }` | A user is created - sign-up, AdminCP, or SSO first sign-in |
| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile or roles) |
| `user.deleted` | `{ userId, email }` | _Declared only_ - core has no user deletion flow yet |
| `role.created` | `{ roleId }` | A role is created in the AdminCP |
| `role.updated` | `{ roleId }` | A role is edited in the AdminCP |
| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP |
| `file.uploaded` | `{ fileId, userId, name, size, mimeType, folder }` | A user uploads a file through the built-in upload endpoint |
| `blog.post.created` | `{ postId, categoryId }` | A blog post is created |
| `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited |
| `blog.post.deleted` | `{ postId }` | A blog post is deleted |
| `blog.category.created` | `{ categoryId }` | A blog category is created |
| `blog.category.updated` | `{ categoryId }` | A blog category is edited |
| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category is deleted (`postIds` is always empty) |

## Core

Expand Down Expand Up @@ -157,6 +158,48 @@ export const roleCleanupListener = buildEventListener({
});
```

### file.uploaded

Emitted once per stored file after a
[user upload](/docs/dev/storage#user-uploads) commits - so a batch of five files
fires five times, and a batch that was rejected or rolled back fires not at all.
Your own upload routes don't emit it; emit it yourself if you want listeners to
treat those files the same way.

<TypeTable
type={{
fileId: {
description: "Id of the new `core_files` row.",
type: "number",
},
userId: {
description: "Owner of the file - the uploader.",
type: "number",
},
name: {
description:
"Stored display name, which differs from the original when image processing changed the format.",
type: "string",
},
size: {
description: "Size in bytes after image processing.",
type: "number",
},
mimeType: {
description: "Stored content type, `null` when the browser sent none.",
type: "string | null",
},
folder: {
description: "Sub-folder the file landed in, e.g. `uploads`.",
type: "string",
},
}}
/>

**Use cases:** index the file for search, kick off a
[queue task](/docs/dev/advanced/queue) for thumbnails or virus scanning, or
audit-log who uploaded what.

### user.deleted (declared only)

This event exists in the `VitNodeEvents` map so listeners and payloads are
Expand All @@ -167,13 +210,12 @@ themselves, and core will emit it once deletion lands.
## Blog (`@vitnode/blog`)

<Callout type="warn" title="These are compatibility adapters now">
The blog runs on the [Content
Engine](/docs/dev/content-engine), so the events that describe what actually
happened are `content.blog.post.*` and `content.blog.category.*` - they carry
changed fields, revision ids, publication transitions, per-locale translation
events and slug history. The four names below are re-emitted from those by
listeners in the plugin, so existing consumers keep working. Prefer the
`content.*` ones for anything new.
The blog runs on the [Content Engine](/docs/dev/content-engine), so the events
that describe what actually happened are `content.blog.post.*` and
`content.blog.category.*` - they carry changed fields, revision ids,
publication transitions, per-locale translation events and slug history. The
four names below are re-emitted from those by listeners in the plugin, so
existing consumers keep working. Prefer the `content.*` ones for anything new.
</Callout>

### blog.category.created / blog.category.updated
Expand Down
62 changes: 58 additions & 4 deletions apps/docs/content/docs/dev/storage/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ VitNode gives you a pluggable **storage adapter**, exposed on every request as
`c.get("storage")`. Files are always stored under `month_{month}_{year}/{folder}/…`
and every upload returns a public URL.

VitNode does **not** ship a generic upload endpoint - you build your own route
For **signed-in users uploading their own files** there is a ready-made endpoint
and a form field - see [user uploads](#user-uploads). For everything else -
an avatar, a plugin's import, anything with its own rules - you build the route
(in your app or plugin) so you control auth, validation, and where files go. The
`c.get("storage").upload()` helper does the heavy lifting: it builds the dated
key, validates the file, and returns the URL.
key, validates the file, and returns the URL plus the `core_files` row it
created.

Storage is **optional**. Without an adapter configured, `c.get("storage")` throws
and the AdminCP → System → Integrations "Storage" card shows as inactive.
Expand Down Expand Up @@ -64,6 +67,55 @@ export const vitNodeApiConfig = buildApiConfig({
});
```

## User uploads

Users uploading their **own** files is common enough that core ships it: a
`POST /users/files` endpoint that takes any number of files at once, and the
[`AutoFormFiles`](/docs/ui/files) field that talks to it.

```tsx
const formSchema = z.object({
attachments: uploadedFilesSchema({ max: 3 }),
});

<AutoFormFiles {...props} label="Attachments" />;
```

**How much** a user may upload is a role setting (AdminCP → Roles → _Content_):
whether they may upload at all, their total quota, and how much one submit may
weigh. Several roles merge into the most generous of them, and `null` -
"unlimited" - beats every number.

**What** they may upload is app configuration:

```ts title="vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: LocalStorageAdapter(),
// [!code ++:5]
uploads: {
allowedMimeTypes: ["image/*", "application/pdf"], // wildcards welcome
maxFiles: 5, // per submit
folder: "attachments", // -> month_7_2026/attachments/…
},
},
});
```

Defaults: raster images, PDF and plain text, up to 10 files, in `uploads`. SVG
is **not** in the default allowlist - it can carry script and stored files are
served from the API origin - so add `image/svg+xml` yourself if you need it.

The batch is validated as a whole _before_ anything is stored, and if an upload
fails halfway the files that already landed are removed again - so a rejected
batch never leaves a half-finished set of attachments behind. Two more routes
round it off: `GET /users/files/upload-limits` (what this user may upload right
now, which is what lets the field refuse a file without spending an upload) and
`DELETE /users/files/{id}`.

Every stored file emits [`file.uploaded`](/docs/dev/events/built-in-events#fileuploaded),
so a plugin can index it, notify someone, or run its own processing.

## Create your own upload endpoint

Define a route that accepts `multipart/form-data`, then call
Expand Down Expand Up @@ -185,8 +237,10 @@ export const useUploadAvatar = () =>

## Uploading multiple files in one endpoint

Read the raw form and loop `upload()` over every file. `formData.getAll("files")`
returns each entry for the repeated `files` field:
If the files belong to the signed-in user, [user uploads](#user-uploads) already
does this. For your own route, read the raw form and loop `upload()` over every
file. `formData.getAll("files")` returns each entry for the repeated `files`
field:

```ts title="upload-gallery.route.ts"
handler: async c => {
Expand Down
187 changes: 187 additions & 0 deletions apps/docs/content/docs/ui/files.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
---
title: Files
description: Upload several files at once - by picking them or dropping them on the field - and keep the stored ones as the form value.
---

## Preview

<Preview name="files" />

## Usage

```ts
import { z } from "zod";
import { AutoForm } from "@vitnode/core/components/form/auto-form";
import { AutoFormFiles } from "@vitnode/core/components/form/fields/files";
import { uploadedFilesSchema } from "@vitnode/core/lib/helpers/files";
```

```ts
const formSchema = z.object({
attachments: uploadedFilesSchema({ max: 3 }),
});
```

```tsx
<AutoForm
formSchema={formSchema}
fields={[
{
id: "attachments",
component: props => (
<AutoFormFiles {...props} label="Attachments" />
),
},
]}
/>
```

That is the whole setup: the field uploads to the built-in
[user upload endpoint](/docs/dev/storage#user-uploads), so you don't write a
route, and it reads the signed-in user's limits from the same place the route
enforces them.

<Callout type="info" title="Value shape">
The value is the list of files that are **already stored** - `{ id, name,
size, mimeType, url }` each - not the browser's `File` objects. Every
selection is uploaded as it is picked, so a submit handler only ever sees ids
that exist. Save those ids; the bytes are already safe.
</Callout>

## What the user is allowed to upload

Nothing here is configured on the field. Uploading is a **role** setting, in
AdminCP → Roles → *Content*:

- **Allow uploading files** - off by default, so a fresh role uploads nothing.
- **Total storage** - the quota across all of the user's files.
- **Storage per submit** - how much one batch may weigh.

A user with several roles gets the most generous of them, and "unlimited" on any
one role wins. *What* may be uploaded - which types, how many files per batch,
which folder - is app configuration:

```ts title="vitnode.api.config.ts"
export const vitNodeApiConfig = buildApiConfig({
storage: {
adapter: LocalStorageAdapter(),
// [!code ++:5]
uploads: {
allowedMimeTypes: ["image/*", "application/pdf"],
maxFiles: 5,
folder: "attachments",
},
},
});
```

The field reads all of it, so it refuses an oversized batch or an unsupported
type **before** uploading, with the same rule the route applies - and shows what
is left as a hint under the button. Files the field uploaded are deleted again
when they are removed from it; a file the form was *opened* with is only
detached, so cancelling an edit never destroys someone's upload.

## Capping the field itself

`maxFiles` narrows the endpoint's limit for one field - useful when a form wants
a single logo even though the role may upload ten:

```tsx
<AutoFormFiles {...props} label="Logo" maxFiles={1} accept="image/*" />
```

A `max()` on the schema does the same, and also fails validation if the value is
tampered with:

```ts
z.object({ logo: uploadedFilesSchema({ max: 1, min: 1 }) });
```

## Pointing it at your own endpoint

Pass `upload`, `remove` and `limits` to use a route of your own - the field then
never calls the core one. Handy for a plugin that stores files somewhere
specific, or for a preview like the one at the top of this page:

```tsx
<AutoFormFiles
{...props}
label="Attachments"
limits={{
allowUpload: true,
allowedMimeTypes: ["image/*"],
maxBytesPerSubmit: 5 * 1024 * 1024,
maxFiles: 3,
maxTotalBytes: null, // unlimited
remainingBytes: null,
usedBytes: 0,
}}
upload={async files => await uploadToMyRoute(files)}
remove={async file => await deleteFromMyRoute(file.id)}
/>
```

## Props

import { TypeTable } from "fumadocs-ui/components/type-table";

<TypeTable
type={{
label: {
description: "The label displayed above the field.",
type: "React.ReactNode",
default: "",
},
description: {
description: "A short description displayed below the field.",
type: "React.ReactNode",
default: "",
},
accept: {
description:
"`accept` for the file input. Defaults to the MIME types the endpoint allows.",
type: "string",
default: "",
},
maxFiles: {
description:
"Caps this field on top of whatever the role and the endpoint allow.",
type: "number",
default: "",
},
disabled: {
description: "Blocks picking and removing files.",
type: "boolean",
default: "false",
},
limits: {
description:
"Skips the request for the user's limits - for previews and custom endpoints.",
type: "UploadFieldLimits",
default: "",
},
upload: {
description:
"Stores a batch and returns the stored files. Defaults to the core upload endpoint.",
type: "(files: File[]) => Promise<UploadedFile[]>",
default: "",
},
remove: {
description:
"Deletes a file this field uploaded. Defaults to the core delete endpoint.",
type: "(file: UploadedFile) => Promise<void>",
default: "",
},
onUploaded: {
description:
"A batch landed - for refreshing whatever else lists the user's files.",
type: "(files: UploadedFile[]) => void",
default: "",
},
onRemoved: {
description: "A file the field uploaded was deleted again.",
type: "(file: UploadedFile) => void",
default: "",
},
}}
/>
1 change: 1 addition & 0 deletions apps/docs/content/docs/ui/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"color",
"combobox",
"editor",
"files",
"input",
"input-group",
"nullable-number",
Expand Down
Loading
Loading