@cfast/ui 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1363,6 +1363,7 @@ export {
1363
1363
  UIPluginProvider,
1364
1364
  useUIPlugin,
1365
1365
  useComponent,
1366
+ ConfirmContext,
1366
1367
  useConfirm,
1367
1368
  ToastContext,
1368
1369
  useToast,
@@ -788,6 +788,130 @@ type JsonFieldProps = BaseFieldProps & {
788
788
  /** Whether to initially show a collapsed preview. */
789
789
  collapsed?: boolean;
790
790
  };
791
+ /**
792
+ * Per-file upload state tracked internally by {@link UploadFieldProps}.
793
+ *
794
+ * Each entry corresponds to a file the user selected (or dropped) into the
795
+ * field. Once a file finishes uploading successfully its `key` is appended
796
+ * to the controlled `value` array and the entry can be removed from this
797
+ * tracker. Failed uploads stay in the tracker so the user can see (and
798
+ * dismiss) the error inline without losing previously uploaded files.
799
+ */
800
+ type UploadFieldFile = {
801
+ /** Stable id for React keying / tracking across renders. */
802
+ id: string;
803
+ /** Display name (taken from `File.name`). */
804
+ name: string;
805
+ /** File size in bytes. */
806
+ size: number;
807
+ /** MIME type as reported by the browser. */
808
+ type: string;
809
+ /** Current upload progress as a 0-100 percentage. */
810
+ progress: number;
811
+ /** Lifecycle status. */
812
+ status: "pending" | "uploading" | "success" | "error";
813
+ /** Server-side R2 key, only set after a successful upload. */
814
+ key?: string;
815
+ /** Inline error message if `status === "error"`. */
816
+ error?: string;
817
+ };
818
+ /**
819
+ * Props for the headless {@link UploadField} component.
820
+ *
821
+ * `UploadField` is a controlled, multi-file upload widget built on the
822
+ * opinionated `POST /uploads/:filetype` route exposed by `@cfast/storage`.
823
+ * Unlike {@link DropZoneProps} (which only wraps a single `useUpload`
824
+ * result), `UploadField` is the form-friendly variant: it owns its own
825
+ * upload state, integrates with `react-hook-form` via the controlled
826
+ * `value`/`onChange` pair, enforces `maxFiles`, and surfaces per-file
827
+ * progress + per-file error reporting.
828
+ *
829
+ * The field reports its current value as `string[]` of R2 keys — exactly
830
+ * the shape you would store on a Drizzle column like `text("image_key")`.
831
+ *
832
+ * @see {@link DropZoneProps} for the lower-level single-upload primitive.
833
+ * @see {@link UploadFieldFile} for the per-file tracker entries.
834
+ */
835
+ type UploadFieldProps = BaseFieldProps & {
836
+ /**
837
+ * The filetype name registered in `defineStorage`. Used as the last
838
+ * segment of the upload POST URL — `${basePath}/${filetype}`.
839
+ */
840
+ filetype: string;
841
+ /**
842
+ * Mount path of the storage routes from `@cfast/storage/plugin`.
843
+ * Defaults to `"/uploads"`. Must match the `basePath` used in
844
+ * `storageRoutes()` / `createStorageRouteHandlers`.
845
+ */
846
+ basePath?: string;
847
+ /**
848
+ * Whether the field accepts multiple files. When `false` (default), the
849
+ * controlled value still flows through as `string[]` but only ever
850
+ * contains zero or one entries.
851
+ */
852
+ multiple?: boolean;
853
+ /**
854
+ * Hard cap on the number of files the user is allowed to upload through
855
+ * this field. Files dropped or selected past the cap are rejected with
856
+ * an inline error and never start uploading. Defaults to `1` when
857
+ * `multiple` is `false`, otherwise unlimited.
858
+ */
859
+ maxFiles?: number;
860
+ /**
861
+ * The HTML `accept` string passed to the underlying `<input type="file">`
862
+ * (e.g. `"image/*"` or `"image/png,image/webp"`). This should match the
863
+ * MIME types declared on the corresponding storage filetype so the
864
+ * browser-side picker can pre-filter selections.
865
+ */
866
+ accept?: string;
867
+ /**
868
+ * Optional client-side max size in bytes. Files larger than this are
869
+ * rejected before any bytes are sent. Server-side validation is still
870
+ * the source of truth — this is only a hint to the user.
871
+ */
872
+ maxSize?: number;
873
+ /**
874
+ * Controlled array of R2 keys. When `multiple` is `false`, treat this
875
+ * as a single-element array (the first entry is the active value).
876
+ */
877
+ value?: string[];
878
+ /**
879
+ * Called with the new array of keys whenever the set of successfully
880
+ * uploaded files changes — after a successful upload, after a removal,
881
+ * etc. Wire this to your `react-hook-form` `setValue` callback or to
882
+ * a controlled component's local state.
883
+ */
884
+ onChange?: (keys: string[]) => void;
885
+ /**
886
+ * Called whenever a per-file upload fails (network error, validation
887
+ * error, server-side rejection). Receives the file tracker entry — the
888
+ * `error` field on the entry is the rendered message.
889
+ */
890
+ onError?: (file: UploadFieldFile) => void;
891
+ /**
892
+ * Whether the field is disabled. Disabled fields render their existing
893
+ * value but don't accept new files and don't render the drop zone in
894
+ * an interactive state.
895
+ */
896
+ disabled?: boolean;
897
+ /**
898
+ * Custom uploader implementation. Defaults to a built-in `XMLHttpRequest`
899
+ * uploader that hits `${basePath}/${filetype}`. Override this in tests
900
+ * (or in apps that wrap the upload in extra logic) — both `@cfast/ui`'s
901
+ * own tests and the Joy variant's tests pass a mock through this prop
902
+ * instead of stubbing `XMLHttpRequest` globally.
903
+ */
904
+ uploader?: (file: File, options: {
905
+ url: string;
906
+ onProgress: (percent: number) => void;
907
+ signal?: AbortSignal;
908
+ }) => Promise<{
909
+ key: string;
910
+ size: number;
911
+ type: string;
912
+ url?: string;
913
+ }>;
914
+ };
791
915
  /**
792
916
  * Toast notification severity levels.
793
917
  *
@@ -1355,6 +1479,10 @@ declare function useComponent<K extends keyof UIPluginComponents>(slot: K): UIPl
1355
1479
  * the user confirms, or `false` if they cancel or dismiss.
1356
1480
  */
1357
1481
  type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
1482
+ type ConfirmContextValue = {
1483
+ confirm: ConfirmFn;
1484
+ };
1485
+ declare const ConfirmContext: react.Context<ConfirmContextValue | null>;
1358
1486
  /**
1359
1487
  * Returns an imperative {@link ConfirmFn} that opens a confirmation dialog
1360
1488
  * and resolves to `true` (confirmed) or `false` (cancelled/dismissed).
@@ -1881,4 +2009,4 @@ declare function ListView<T = unknown>({ title, data, table: _table, columns, ac
1881
2009
  */
1882
2010
  declare function DetailView<T = unknown>({ title, table, record, fields: fieldsProp, exclude, breadcrumb, }: DetailViewProps<T>): react_jsx_runtime.JSX.Element;
1883
2011
 
1884
- export { ImagePreview as $, type AppShellProps as A, type BooleanFieldProps as B, type ChipSlotProps as C, type DateFieldProps as D, type EmailFieldProps as E, type FileFieldProps as F, type DetailViewProps as G, DropZone as H, type ImageFieldProps as I, type JsonFieldProps as J, type DropZoneProps as K, type DropZoneSlotProps as L, FileList$1 as M, type NumberFieldProps as N, type FileListFile as O, type FileListProps as P, FilterBar as Q, type RelationFieldProps as R, type FilterBarProps as S, type TextFieldProps as T, type UrlFieldProps as U, type FilterDef as V, type FilterOption as W, type FilterType as X, FormStatus as Y, type FormStatusData as Z, type FormStatusProps as _, type FieldComponent as a, type ImagePreviewProps as a0, ImpersonationBanner as a1, type ImpersonationBannerProps as a2, ListView as a3, type ListViewProps as a4, type PageContainerSlotProps as a5, PermissionGate as a6, type PermissionGateProps as a7, RoleBadge as a8, type RoleBadgeProps as a9, type SidebarSlotProps as aa, type TableCellSlotProps as ab, type TableRowSlotProps as ac, type TableSectionSlotProps as ad, type TableSlotProps as ae, type ToastApi as af, ToastContext as ag, type ToastOptions as ah, type ToastSlotProps as ai, type ToastType as aj, type TooltipSlotProps as ak, type UIPlugin as al, type UIPluginComponents as am, UIPluginProvider as an, type UserMenuLink as ao, type WhenForbidden as ap, createUIPlugin as aq, getInitials as ar, useActionToast as as, useColumnInference as at, useComponent as au, useConfirm as av, useToast as aw, useUIPlugin as ax, type EmptyStateProps as b, type NavigationProgressProps as c, type BreadcrumbItem as d, type TabItem as e, type NavigationItem as f, type UserMenuProps as g, ActionButton as h, type ActionButtonProps as i, type AlertSlotProps as j, type AppShellSlotProps as k, AvatarWithInitials as l, type AvatarWithInitialsProps as m, type BaseFieldProps as n, type BreadcrumbSlotProps as o, type BulkAction as p, BulkActionBar as q, type ButtonSlotProps as r, type ColumnDef as s, type ColumnShorthand as t, type ConfirmDialogSlotProps as u, type ConfirmOptions as v, ConfirmProvider as w, DataTable as x, type DataTableProps as y, DetailView as z };
2012
+ export { type FormStatusData as $, type AppShellProps as A, type BooleanFieldProps as B, type ChipSlotProps as C, type DateFieldProps as D, type EmailFieldProps as E, type FileFieldProps as F, type DataTableProps as G, DetailView as H, type ImageFieldProps as I, type JsonFieldProps as J, type DetailViewProps as K, DropZone as L, type DropZoneProps as M, type NumberFieldProps as N, type DropZoneSlotProps as O, FileList$1 as P, type FileListFile as Q, type RelationFieldProps as R, type FileListProps as S, type TextFieldProps as T, type UrlFieldProps as U, FilterBar as V, type FilterBarProps as W, type FilterDef as X, type FilterOption as Y, type FilterType as Z, FormStatus as _, type UploadFieldProps as a, type FormStatusProps as a0, ImagePreview as a1, type ImagePreviewProps as a2, ImpersonationBanner as a3, type ImpersonationBannerProps as a4, ListView as a5, type ListViewProps as a6, type PageContainerSlotProps as a7, PermissionGate as a8, type PermissionGateProps as a9, useUIPlugin as aA, RoleBadge as aa, type RoleBadgeProps as ab, type SidebarSlotProps as ac, type TableCellSlotProps as ad, type TableRowSlotProps as ae, type TableSectionSlotProps as af, type TableSlotProps as ag, type ToastApi as ah, ToastContext as ai, type ToastOptions as aj, type ToastSlotProps as ak, type ToastType as al, type TooltipSlotProps as am, type UIPlugin as an, type UIPluginComponents as ao, UIPluginProvider as ap, type UploadFieldFile as aq, type UserMenuLink as ar, type WhenForbidden as as, createUIPlugin as at, getInitials as au, useActionToast as av, useColumnInference as aw, useComponent as ax, useConfirm as ay, useToast as az, type FieldComponent as b, type EmptyStateProps as c, type NavigationProgressProps as d, type BreadcrumbItem as e, type TabItem as f, type NavigationItem as g, type UserMenuProps as h, ActionButton as i, type ActionButtonProps as j, type AlertSlotProps as k, type AppShellSlotProps as l, AvatarWithInitials as m, type AvatarWithInitialsProps as n, type BaseFieldProps as o, type BreadcrumbSlotProps as p, type BulkAction as q, BulkActionBar as r, type ButtonSlotProps as s, type ColumnDef as t, type ColumnShorthand as u, ConfirmContext as v, type ConfirmDialogSlotProps as w, type ConfirmOptions as x, ConfirmProvider as y, DataTable as z };
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { h as ActionButton, l as AvatarWithInitials, q as BulkActionBar, w as ConfirmProvider, x as DataTable, z as DetailView, H as DropZone, M as FileList, Q as FilterBar, Y as FormStatus, $ as ImagePreview, a1 as ImpersonationBanner, a3 as ListView, a6 as PermissionGate, a8 as RoleBadge, an as UIPluginProvider, aq as createUIPlugin, ar as getInitials, as as useActionToast, at as useColumnInference, au as useComponent, av as useConfirm, aw as useToast, ax as useUIPlugin } from './client-CIx8_tmv.js';
1
+ export { i as ActionButton, m as AvatarWithInitials, r as BulkActionBar, y as ConfirmProvider, z as DataTable, H as DetailView, L as DropZone, P as FileList, V as FilterBar, _ as FormStatus, a1 as ImagePreview, a3 as ImpersonationBanner, a5 as ListView, a8 as PermissionGate, aa as RoleBadge, ap as UIPluginProvider, at as createUIPlugin, au as getInitials, av as useActionToast, aw as useColumnInference, ax as useComponent, ay as useConfirm, az as useToast, aA as useUIPlugin } from './client-DcCN1BR-.js';
2
2
  import 'react/jsx-runtime';
3
3
  import 'react';
4
4
  import '@cfast/actions/client';
package/dist/client.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  useConfirm,
24
24
  useToast,
25
25
  useUIPlugin
26
- } from "./chunk-PWBG6CGF.js";
26
+ } from "./chunk-TLLCSKE5.js";
27
27
  export {
28
28
  ActionButton,
29
29
  AvatarWithInitials,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { D as DateFieldProps, B as BooleanFieldProps, N as NumberFieldProps, T as TextFieldProps, E as EmailFieldProps, U as UrlFieldProps, I as ImageFieldProps, F as FileFieldProps, R as RelationFieldProps, J as JsonFieldProps, a as FieldComponent, b as EmptyStateProps, c as NavigationProgressProps, d as BreadcrumbItem, e as TabItem, A as AppShellProps, f as NavigationItem, g as UserMenuProps } from './client-CIx8_tmv.js';
2
- export { h as ActionButton, i as ActionButtonProps, j as AlertSlotProps, k as AppShellSlotProps, l as AvatarWithInitials, m as AvatarWithInitialsProps, n as BaseFieldProps, o as BreadcrumbSlotProps, p as BulkAction, q as BulkActionBar, r as ButtonSlotProps, C as ChipSlotProps, s as ColumnDef, t as ColumnShorthand, u as ConfirmDialogSlotProps, v as ConfirmOptions, w as ConfirmProvider, x as DataTable, y as DataTableProps, z as DetailView, G as DetailViewProps, H as DropZone, K as DropZoneProps, L as DropZoneSlotProps, M as FileList, O as FileListFile, P as FileListProps, Q as FilterBar, S as FilterBarProps, V as FilterDef, W as FilterOption, X as FilterType, Y as FormStatus, Z as FormStatusData, _ as FormStatusProps, $ as ImagePreview, a0 as ImagePreviewProps, a1 as ImpersonationBanner, a2 as ImpersonationBannerProps, a3 as ListView, a4 as ListViewProps, a5 as PageContainerSlotProps, a6 as PermissionGate, a7 as PermissionGateProps, a8 as RoleBadge, a9 as RoleBadgeProps, aa as SidebarSlotProps, ab as TableCellSlotProps, ac as TableRowSlotProps, ad as TableSectionSlotProps, ae as TableSlotProps, af as ToastApi, ag as ToastContext, ah as ToastOptions, ai as ToastSlotProps, aj as ToastType, ak as TooltipSlotProps, al as UIPlugin, am as UIPluginComponents, an as UIPluginProvider, ao as UserMenuLink, ap as WhenForbidden, aq as createUIPlugin, ar as getInitials, as as useActionToast, at as useColumnInference, au as useComponent, av as useConfirm, aw as useToast, ax as useUIPlugin } from './client-CIx8_tmv.js';
1
+ import { D as DateFieldProps, B as BooleanFieldProps, N as NumberFieldProps, T as TextFieldProps, E as EmailFieldProps, U as UrlFieldProps, I as ImageFieldProps, F as FileFieldProps, R as RelationFieldProps, J as JsonFieldProps, a as UploadFieldProps, b as FieldComponent, c as EmptyStateProps, d as NavigationProgressProps, e as BreadcrumbItem, f as TabItem, A as AppShellProps, g as NavigationItem, h as UserMenuProps } from './client-DcCN1BR-.js';
2
+ export { i as ActionButton, j as ActionButtonProps, k as AlertSlotProps, l as AppShellSlotProps, m as AvatarWithInitials, n as AvatarWithInitialsProps, o as BaseFieldProps, p as BreadcrumbSlotProps, q as BulkAction, r as BulkActionBar, s as ButtonSlotProps, C as ChipSlotProps, t as ColumnDef, u as ColumnShorthand, v as ConfirmContext, w as ConfirmDialogSlotProps, x as ConfirmOptions, y as ConfirmProvider, z as DataTable, G as DataTableProps, H as DetailView, K as DetailViewProps, L as DropZone, M as DropZoneProps, O as DropZoneSlotProps, P as FileList, Q as FileListFile, S as FileListProps, V as FilterBar, W as FilterBarProps, X as FilterDef, Y as FilterOption, Z as FilterType, _ as FormStatus, $ as FormStatusData, a0 as FormStatusProps, a1 as ImagePreview, a2 as ImagePreviewProps, a3 as ImpersonationBanner, a4 as ImpersonationBannerProps, a5 as ListView, a6 as ListViewProps, a7 as PageContainerSlotProps, a8 as PermissionGate, a9 as PermissionGateProps, aa as RoleBadge, ab as RoleBadgeProps, ac as SidebarSlotProps, ad as TableCellSlotProps, ae as TableRowSlotProps, af as TableSectionSlotProps, ag as TableSlotProps, ah as ToastApi, ai as ToastContext, aj as ToastOptions, ak as ToastSlotProps, al as ToastType, am as TooltipSlotProps, an as UIPlugin, ao as UIPluginComponents, ap as UIPluginProvider, aq as UploadFieldFile, ar as UserMenuLink, as as WhenForbidden, at as createUIPlugin, au as getInitials, av as useActionToast, aw as useColumnInference, ax as useComponent, ay as useConfirm, az as useToast, aA as useUIPlugin } from './client-DcCN1BR-.js';
3
3
  import * as react_jsx_runtime from 'react/jsx-runtime';
4
4
  import { ReactNode } from 'react';
5
5
  import { ClientDescriptor, ActionHookResult } from '@cfast/actions/client';
@@ -188,6 +188,39 @@ declare function RelationField({ value, display, linkTo, }: RelationFieldProps):
188
188
  */
189
189
  declare function JsonField({ value, collapsed, }: JsonFieldProps): react_jsx_runtime.JSX.Element;
190
190
 
191
+ /**
192
+ * Headless multi-file upload field for cfast forms.
193
+ *
194
+ * `UploadField` is a controlled, form-friendly component that uploads files
195
+ * through `@cfast/storage`'s opinionated `POST /uploads/:filetype` route
196
+ * and surfaces the resulting R2 keys via the `value` / `onChange` pair.
197
+ *
198
+ * It is intentionally headless: the markup is plain HTML so consumers can
199
+ * style it directly, and the Joy UI variant lives in `@cfast/joy`. The
200
+ * component handles:
201
+ *
202
+ * - Drag-and-drop and click-to-browse selection.
203
+ * - Per-file upload progress (via the configurable {@link UploadFieldProps.uploader}).
204
+ * - Per-file inline error reporting (server, network, or validation).
205
+ * - `accept` / `maxSize` / `maxFiles` enforcement before bytes leave the browser.
206
+ * - Controlled `value` round-tripping for `react-hook-form` and other state libs.
207
+ *
208
+ * @example
209
+ * ```tsx
210
+ * <UploadField
211
+ * filetype="productImages"
212
+ * basePath="/uploads"
213
+ * multiple
214
+ * maxFiles={10}
215
+ * accept="image/*"
216
+ * value={imageKeys}
217
+ * onChange={setImageKeys}
218
+ * onError={(file) => console.warn(file.error)}
219
+ * />
220
+ * ```
221
+ */
222
+ declare function UploadField({ label, className, filetype, basePath, multiple, maxFiles, accept, maxSize, value, onChange, onError, disabled, uploader, }: UploadFieldProps): react_jsx_runtime.JSX.Element;
223
+
191
224
  type ColumnMeta = {
192
225
  dataType: string;
193
226
  name: string;
@@ -459,4 +492,4 @@ declare function getRecordId(obj: unknown): string | number;
459
492
  */
460
493
  declare function useActionStatus(descriptor: ClientDescriptor): ActionHookResult;
461
494
 
462
- export { AppShell, AppShellHeader, AppShellProps, AppShellSidebar, BooleanField, BooleanFieldProps, BreadcrumbItem, DateField, DateFieldProps, EmailField, EmailFieldProps, EmptyState, EmptyStateProps, FileField, FileFieldProps, ImageField, ImageFieldProps, JsonField, JsonFieldProps, NavigationItem, NavigationProgress, NavigationProgressProps, NumberField, NumberFieldProps, PageContainer, RelationField, RelationFieldProps, TabItem, TextField, TextFieldProps, UrlField, UrlFieldProps, UserMenu, UserMenuProps, fieldForColumn, fieldsForTable, getField, getRecordId, useActionStatus };
495
+ export { AppShell, AppShellHeader, AppShellProps, AppShellSidebar, BooleanField, BooleanFieldProps, BreadcrumbItem, DateField, DateFieldProps, EmailField, EmailFieldProps, EmptyState, EmptyStateProps, FileField, FileFieldProps, ImageField, ImageFieldProps, JsonField, JsonFieldProps, NavigationItem, NavigationProgress, NavigationProgressProps, NumberField, NumberFieldProps, PageContainer, RelationField, RelationFieldProps, TabItem, TextField, TextFieldProps, UploadField, UploadFieldProps, UrlField, UrlFieldProps, UserMenu, UserMenuProps, fieldForColumn, fieldsForTable, getField, getRecordId, useActionStatus };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  AvatarWithInitials,
4
4
  BooleanField,
5
5
  BulkActionBar,
6
+ ConfirmContext,
6
7
  ConfirmProvider,
7
8
  DataTable,
8
9
  DateField,
@@ -37,7 +38,7 @@ import {
37
38
  useConfirm,
38
39
  useToast,
39
40
  useUIPlugin
40
- } from "./chunk-PWBG6CGF.js";
41
+ } from "./chunk-TLLCSKE5.js";
41
42
 
42
43
  // src/fields/url-field.tsx
43
44
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -125,9 +126,428 @@ function RelationField({
125
126
  return /* @__PURE__ */ jsx4("span", { children: displayValue });
126
127
  }
127
128
 
129
+ // src/fields/upload-field.tsx
130
+ import { useCallback, useId, useRef, useState } from "react";
131
+ import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
132
+ function defaultUploader(file, options) {
133
+ return new Promise((resolve, reject) => {
134
+ const xhr = new XMLHttpRequest();
135
+ xhr.open("POST", options.url);
136
+ xhr.upload.addEventListener("progress", (event) => {
137
+ if (event.lengthComputable) {
138
+ options.onProgress(Math.round(event.loaded / event.total * 100));
139
+ }
140
+ });
141
+ xhr.addEventListener("load", () => {
142
+ if (xhr.status >= 200 && xhr.status < 300) {
143
+ try {
144
+ const parsed = JSON.parse(xhr.responseText);
145
+ if (typeof parsed.key === "string" && typeof parsed.size === "number" && typeof parsed.type === "string") {
146
+ resolve({
147
+ key: parsed.key,
148
+ size: parsed.size,
149
+ type: parsed.type,
150
+ url: typeof parsed.url === "string" ? parsed.url : void 0
151
+ });
152
+ return;
153
+ }
154
+ reject(new Error("Invalid response from server"));
155
+ } catch {
156
+ reject(new Error("Invalid response from server"));
157
+ }
158
+ } else {
159
+ try {
160
+ const parsed = JSON.parse(xhr.responseText);
161
+ const detail = typeof parsed.detail === "string" ? parsed.detail : typeof parsed.message === "string" ? parsed.message : `Upload failed (${xhr.status})`;
162
+ reject(new Error(detail));
163
+ } catch {
164
+ reject(new Error(`Upload failed (${xhr.status})`));
165
+ }
166
+ }
167
+ });
168
+ xhr.addEventListener("error", () => {
169
+ reject(new Error("Network error during upload"));
170
+ });
171
+ if (options.signal) {
172
+ options.signal.addEventListener("abort", () => {
173
+ xhr.abort();
174
+ reject(new Error("Upload cancelled"));
175
+ });
176
+ }
177
+ const formData = new FormData();
178
+ formData.append("file", file);
179
+ xhr.send(formData);
180
+ });
181
+ }
182
+ function matchesAccept(file, accept) {
183
+ if (!accept) return true;
184
+ const tokens = accept.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
185
+ if (tokens.length === 0) return true;
186
+ const fileType = (file.type || "").toLowerCase();
187
+ const fileName = file.name.toLowerCase();
188
+ for (const token of tokens) {
189
+ if (token.startsWith(".")) {
190
+ if (fileName.endsWith(token)) return true;
191
+ } else if (token.endsWith("/*")) {
192
+ const prefix = token.slice(0, -1);
193
+ if (fileType.startsWith(prefix)) return true;
194
+ } else if (token === fileType) {
195
+ return true;
196
+ }
197
+ }
198
+ return false;
199
+ }
200
+ function formatBytes2(bytes) {
201
+ if (bytes < 1024) return `${bytes} B`;
202
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
203
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
204
+ }
205
+ function UploadField({
206
+ label,
207
+ className,
208
+ filetype,
209
+ basePath = "/uploads",
210
+ multiple = false,
211
+ maxFiles,
212
+ accept,
213
+ maxSize,
214
+ value,
215
+ onChange,
216
+ onError,
217
+ disabled = false,
218
+ uploader = defaultUploader
219
+ }) {
220
+ const [files, setFiles] = useState([]);
221
+ const [isDragOver, setIsDragOver] = useState(false);
222
+ const [globalError, setGlobalError] = useState(null);
223
+ const inputRef = useRef(null);
224
+ const reactId = useId();
225
+ const normalizedBase = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
226
+ const uploadUrl = `${normalizedBase}/${filetype}`;
227
+ const effectiveMax = maxFiles ?? (multiple ? Number.POSITIVE_INFINITY : 1);
228
+ const currentValue = value ?? [];
229
+ const appendKey = useCallback(
230
+ (key) => {
231
+ const next = [...currentValue, key];
232
+ onChange?.(next);
233
+ },
234
+ [currentValue, onChange]
235
+ );
236
+ const removeKey = useCallback(
237
+ (key) => {
238
+ const next = currentValue.filter((k) => k !== key);
239
+ onChange?.(next);
240
+ },
241
+ [currentValue, onChange]
242
+ );
243
+ const removeTrackerEntry = useCallback((id) => {
244
+ setFiles((prev) => prev.filter((f) => f.id !== id));
245
+ }, []);
246
+ const handleFiles = useCallback(
247
+ async (incoming) => {
248
+ if (disabled) return;
249
+ setGlobalError(null);
250
+ const list = Array.from(incoming);
251
+ if (list.length === 0) return;
252
+ const inFlight = files.filter((f) => f.status !== "error").length;
253
+ const remainingSlots = effectiveMax - currentValue.length - inFlight;
254
+ if (remainingSlots <= 0) {
255
+ setGlobalError(
256
+ `Maximum of ${effectiveMax} file${effectiveMax === 1 ? "" : "s"} reached`
257
+ );
258
+ return;
259
+ }
260
+ if (list.length > remainingSlots) {
261
+ setGlobalError(
262
+ `Only ${remainingSlots} more file${remainingSlots === 1 ? "" : "s"} allowed`
263
+ );
264
+ list.length = remainingSlots;
265
+ }
266
+ const accepted = [];
267
+ for (const file of list) {
268
+ const id = `${reactId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
269
+ if (!matchesAccept(file, accept)) {
270
+ const errEntry = {
271
+ id,
272
+ name: file.name,
273
+ size: file.size,
274
+ type: file.type,
275
+ progress: 0,
276
+ status: "error",
277
+ error: `${file.type || "file"} is not an accepted type`
278
+ };
279
+ accepted.push({ entry: errEntry, file });
280
+ onError?.(errEntry);
281
+ continue;
282
+ }
283
+ if (maxSize != null && file.size > maxSize) {
284
+ const errEntry = {
285
+ id,
286
+ name: file.name,
287
+ size: file.size,
288
+ type: file.type,
289
+ progress: 0,
290
+ status: "error",
291
+ error: `File is ${formatBytes2(file.size)} but max is ${formatBytes2(maxSize)}`
292
+ };
293
+ accepted.push({ entry: errEntry, file });
294
+ onError?.(errEntry);
295
+ continue;
296
+ }
297
+ accepted.push({
298
+ entry: {
299
+ id,
300
+ name: file.name,
301
+ size: file.size,
302
+ type: file.type,
303
+ progress: 0,
304
+ status: "pending"
305
+ },
306
+ file
307
+ });
308
+ }
309
+ setFiles((prev) => [...prev, ...accepted.map((a) => a.entry)]);
310
+ for (const { entry, file } of accepted) {
311
+ if (entry.status === "error") continue;
312
+ setFiles(
313
+ (prev) => prev.map(
314
+ (f) => f.id === entry.id ? { ...f, status: "uploading" } : f
315
+ )
316
+ );
317
+ try {
318
+ const result = await uploader(file, {
319
+ url: uploadUrl,
320
+ onProgress: (percent) => {
321
+ setFiles(
322
+ (prev) => prev.map(
323
+ (f) => f.id === entry.id ? { ...f, progress: percent } : f
324
+ )
325
+ );
326
+ }
327
+ });
328
+ setFiles(
329
+ (prev) => prev.map(
330
+ (f) => f.id === entry.id ? { ...f, status: "success", progress: 100, key: result.key } : f
331
+ )
332
+ );
333
+ appendKey(result.key);
334
+ setFiles((prev) => prev.filter((f) => f.id !== entry.id));
335
+ } catch (e) {
336
+ const message = e instanceof Error ? e.message : String(e);
337
+ const errEntry = {
338
+ ...entry,
339
+ status: "error",
340
+ error: message
341
+ };
342
+ setFiles(
343
+ (prev) => prev.map((f) => f.id === entry.id ? errEntry : f)
344
+ );
345
+ onError?.(errEntry);
346
+ }
347
+ }
348
+ },
349
+ [
350
+ accept,
351
+ appendKey,
352
+ currentValue.length,
353
+ disabled,
354
+ effectiveMax,
355
+ files,
356
+ maxSize,
357
+ onError,
358
+ reactId,
359
+ uploadUrl,
360
+ uploader
361
+ ]
362
+ );
363
+ const handleDrop = useCallback(
364
+ (e) => {
365
+ e.preventDefault();
366
+ setIsDragOver(false);
367
+ void handleFiles(e.dataTransfer.files);
368
+ },
369
+ [handleFiles]
370
+ );
371
+ const handleDragOver = useCallback(
372
+ (e) => {
373
+ e.preventDefault();
374
+ if (!disabled) setIsDragOver(true);
375
+ },
376
+ [disabled]
377
+ );
378
+ const handleDragLeave = useCallback(() => {
379
+ setIsDragOver(false);
380
+ }, []);
381
+ const handleClick = useCallback(() => {
382
+ if (disabled) return;
383
+ inputRef.current?.click();
384
+ }, [disabled]);
385
+ const handleInputChange = useCallback(
386
+ (e) => {
387
+ void handleFiles(e.target.files ?? []);
388
+ e.target.value = "";
389
+ },
390
+ [handleFiles]
391
+ );
392
+ return /* @__PURE__ */ jsxs2("div", { className, "data-cfast-upload-field": filetype, children: [
393
+ label != null ? /* @__PURE__ */ jsx5(
394
+ "label",
395
+ {
396
+ htmlFor: `${reactId}-input`,
397
+ style: { display: "block", marginBottom: 4 },
398
+ children: label
399
+ }
400
+ ) : null,
401
+ /* @__PURE__ */ jsx5(
402
+ "div",
403
+ {
404
+ role: "button",
405
+ tabIndex: disabled ? -1 : 0,
406
+ "aria-disabled": disabled,
407
+ "data-drag-over": isDragOver,
408
+ "data-testid": "upload-field-drop-zone",
409
+ onClick: handleClick,
410
+ onDrop: handleDrop,
411
+ onDragOver: handleDragOver,
412
+ onDragLeave: handleDragLeave,
413
+ onKeyDown: (e) => {
414
+ if (e.key === "Enter" || e.key === " ") {
415
+ e.preventDefault();
416
+ handleClick();
417
+ }
418
+ },
419
+ style: {
420
+ border: "2px dashed",
421
+ borderColor: isDragOver ? "#1976d2" : "#ccc",
422
+ borderRadius: 4,
423
+ padding: 16,
424
+ textAlign: "center",
425
+ cursor: disabled ? "not-allowed" : "pointer",
426
+ opacity: disabled ? 0.6 : 1
427
+ },
428
+ children: multiple ? "Drop files here or click to browse" : "Drop a file here or click to browse"
429
+ }
430
+ ),
431
+ /* @__PURE__ */ jsx5(
432
+ "input",
433
+ {
434
+ ref: inputRef,
435
+ id: `${reactId}-input`,
436
+ type: "file",
437
+ accept,
438
+ multiple,
439
+ disabled,
440
+ style: { display: "none" },
441
+ onChange: handleInputChange,
442
+ "data-testid": "upload-field-input"
443
+ }
444
+ ),
445
+ globalError != null ? /* @__PURE__ */ jsx5("div", { role: "alert", "data-testid": "upload-field-global-error", style: { color: "#d32f2f", marginTop: 8 }, children: globalError }) : null,
446
+ currentValue.length > 0 ? /* @__PURE__ */ jsx5(
447
+ "ul",
448
+ {
449
+ "data-testid": "upload-field-value-list",
450
+ style: { listStyle: "none", padding: 0, margin: "8px 0 0 0" },
451
+ children: currentValue.map((key) => /* @__PURE__ */ jsxs2(
452
+ "li",
453
+ {
454
+ "data-testid": "upload-field-value-item",
455
+ style: {
456
+ display: "flex",
457
+ alignItems: "center",
458
+ gap: 8,
459
+ padding: "4px 0"
460
+ },
461
+ children: [
462
+ /* @__PURE__ */ jsx5("span", { style: { flex: 1, fontSize: "0.85em" }, children: key }),
463
+ /* @__PURE__ */ jsx5(
464
+ "button",
465
+ {
466
+ type: "button",
467
+ onClick: () => removeKey(key),
468
+ disabled,
469
+ "aria-label": `Remove ${key}`,
470
+ style: {
471
+ background: "none",
472
+ border: "none",
473
+ cursor: disabled ? "not-allowed" : "pointer",
474
+ color: "#d32f2f"
475
+ },
476
+ children: "\xD7"
477
+ }
478
+ )
479
+ ]
480
+ },
481
+ key
482
+ ))
483
+ }
484
+ ) : null,
485
+ files.length > 0 ? /* @__PURE__ */ jsx5(
486
+ "ul",
487
+ {
488
+ "data-testid": "upload-field-tracker",
489
+ style: { listStyle: "none", padding: 0, margin: "8px 0 0 0" },
490
+ children: files.map((file) => /* @__PURE__ */ jsxs2(
491
+ "li",
492
+ {
493
+ "data-testid": "upload-field-tracker-item",
494
+ "data-status": file.status,
495
+ style: {
496
+ display: "flex",
497
+ flexDirection: "column",
498
+ gap: 4,
499
+ padding: "4px 0"
500
+ },
501
+ children: [
502
+ /* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
503
+ /* @__PURE__ */ jsx5("span", { style: { flex: 1 }, children: file.name }),
504
+ /* @__PURE__ */ jsx5("span", { style: { color: "#666", fontSize: "0.85em" }, children: formatBytes2(file.size) }),
505
+ /* @__PURE__ */ jsx5(
506
+ "button",
507
+ {
508
+ type: "button",
509
+ onClick: () => removeTrackerEntry(file.id),
510
+ "aria-label": `Dismiss ${file.name}`,
511
+ style: {
512
+ background: "none",
513
+ border: "none",
514
+ cursor: "pointer",
515
+ color: "#666"
516
+ },
517
+ children: "\xD7"
518
+ }
519
+ )
520
+ ] }),
521
+ file.status === "uploading" ? /* @__PURE__ */ jsx5(
522
+ "progress",
523
+ {
524
+ "data-testid": "upload-field-progress",
525
+ value: file.progress,
526
+ max: 100,
527
+ style: { width: "100%" }
528
+ }
529
+ ) : null,
530
+ file.status === "error" && file.error != null ? /* @__PURE__ */ jsx5(
531
+ "div",
532
+ {
533
+ role: "alert",
534
+ "data-testid": "upload-field-file-error",
535
+ style: { color: "#d32f2f", fontSize: "0.85em" },
536
+ children: file.error
537
+ }
538
+ ) : null
539
+ ]
540
+ },
541
+ file.id
542
+ ))
543
+ }
544
+ ) : null
545
+ ] });
546
+ }
547
+
128
548
  // src/components/navigation-progress.tsx
129
549
  import { useNavigation } from "react-router";
130
- import { jsx as jsx5 } from "react/jsx-runtime";
550
+ import { jsx as jsx6 } from "react/jsx-runtime";
131
551
  function NavigationProgress({
132
552
  color = "#1976d2"
133
553
  }) {
@@ -136,7 +556,7 @@ function NavigationProgress({
136
556
  if (!isNavigating) {
137
557
  return null;
138
558
  }
139
- return /* @__PURE__ */ jsx5(
559
+ return /* @__PURE__ */ jsx6(
140
560
  "div",
141
561
  {
142
562
  role: "progressbar",
@@ -156,32 +576,32 @@ function NavigationProgress({
156
576
  }
157
577
 
158
578
  // src/components/app-shell.tsx
159
- import { Fragment, jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
579
+ import { Fragment, jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
160
580
  function AppShell({ children, sidebar, header }) {
161
581
  const Shell = useComponent("appShell");
162
- return /* @__PURE__ */ jsx6(Shell, { sidebar, header, children });
582
+ return /* @__PURE__ */ jsx7(Shell, { sidebar, header, children });
163
583
  }
164
584
  function AppShellSidebar({ items }) {
165
- return /* @__PURE__ */ jsx6("nav", { children: /* @__PURE__ */ jsx6("ul", { style: { listStyle: "none", padding: 0, margin: 0 }, children: items.map((item) => /* @__PURE__ */ jsx6(SidebarItem, { item }, item.to)) }) });
585
+ return /* @__PURE__ */ jsx7("nav", { children: /* @__PURE__ */ jsx7("ul", { style: { listStyle: "none", padding: 0, margin: 0 }, children: items.map((item) => /* @__PURE__ */ jsx7(SidebarItem, { item }, item.to)) }) });
166
586
  }
167
587
  function SidebarItem({ item }) {
168
588
  if (item.action) {
169
- return /* @__PURE__ */ jsx6(PermissionFilteredItem, { item });
589
+ return /* @__PURE__ */ jsx7(PermissionFilteredItem, { item });
170
590
  }
171
- return /* @__PURE__ */ jsx6("li", { children: /* @__PURE__ */ jsx6("a", { href: item.to, children: item.label }) });
591
+ return /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsx7("a", { href: item.to, children: item.label }) });
172
592
  }
173
593
  function PermissionFilteredItem({ item }) {
174
594
  const status = useActionStatus(item.action);
175
595
  if (status.invisible || !status.permitted) {
176
596
  return null;
177
597
  }
178
- return /* @__PURE__ */ jsx6("li", { children: /* @__PURE__ */ jsx6("a", { href: item.to, children: item.label }) });
598
+ return /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsx7("a", { href: item.to, children: item.label }) });
179
599
  }
180
600
  function AppShellHeader({
181
601
  children,
182
602
  userMenu
183
603
  }) {
184
- return /* @__PURE__ */ jsxs2(
604
+ return /* @__PURE__ */ jsxs3(
185
605
  "header",
186
606
  {
187
607
  style: {
@@ -192,7 +612,7 @@ function AppShellHeader({
192
612
  borderBottom: "1px solid #ddd"
193
613
  },
194
614
  children: [
195
- children ?? /* @__PURE__ */ jsx6(Fragment, {}),
615
+ children ?? /* @__PURE__ */ jsx7(Fragment, {}),
196
616
  userMenu ?? null
197
617
  ]
198
618
  }
@@ -203,7 +623,7 @@ AppShell.Header = AppShellHeader;
203
623
 
204
624
  // src/components/user-menu.tsx
205
625
  import { useCurrentUser } from "@cfast/auth/client";
206
- import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
626
+ import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
207
627
  function UserMenu({
208
628
  links = [],
209
629
  onSignOut
@@ -212,8 +632,8 @@ function UserMenu({
212
632
  if (!user) {
213
633
  return null;
214
634
  }
215
- return /* @__PURE__ */ jsxs3("div", { style: { position: "relative" }, children: [
216
- /* @__PURE__ */ jsx7(
635
+ return /* @__PURE__ */ jsxs4("div", { style: { position: "relative" }, children: [
636
+ /* @__PURE__ */ jsx8(
217
637
  AvatarWithInitials,
218
638
  {
219
639
  src: user.avatarUrl,
@@ -221,17 +641,17 @@ function UserMenu({
221
641
  size: "sm"
222
642
  }
223
643
  ),
224
- /* @__PURE__ */ jsxs3("div", { className: "user-menu-dropdown", children: [
225
- /* @__PURE__ */ jsxs3("div", { children: [
226
- /* @__PURE__ */ jsx7("strong", { children: user.name }),
227
- /* @__PURE__ */ jsx7("br", {}),
228
- /* @__PURE__ */ jsx7("small", { children: user.email })
644
+ /* @__PURE__ */ jsxs4("div", { className: "user-menu-dropdown", children: [
645
+ /* @__PURE__ */ jsxs4("div", { children: [
646
+ /* @__PURE__ */ jsx8("strong", { children: user.name }),
647
+ /* @__PURE__ */ jsx8("br", {}),
648
+ /* @__PURE__ */ jsx8("small", { children: user.email })
229
649
  ] }),
230
- user.roles.length > 0 ? /* @__PURE__ */ jsx7("div", { style: { display: "flex", gap: "4px", marginTop: "4px" }, children: user.roles.map((role) => /* @__PURE__ */ jsx7(RoleBadge, { role }, role)) }) : null,
650
+ user.roles.length > 0 ? /* @__PURE__ */ jsx8("div", { style: { display: "flex", gap: "4px", marginTop: "4px" }, children: user.roles.map((role) => /* @__PURE__ */ jsx8(RoleBadge, { role }, role)) }) : null,
231
651
  links.map(
232
- (link) => link.action ? /* @__PURE__ */ jsx7(PermissionFilteredLink, { link }, link.to) : /* @__PURE__ */ jsx7("a", { href: link.to, children: link.label }, link.to)
652
+ (link) => link.action ? /* @__PURE__ */ jsx8(PermissionFilteredLink, { link }, link.to) : /* @__PURE__ */ jsx8("a", { href: link.to, children: link.label }, link.to)
233
653
  ),
234
- onSignOut ? /* @__PURE__ */ jsx7(
654
+ onSignOut ? /* @__PURE__ */ jsx8(
235
655
  "button",
236
656
  {
237
657
  onClick: onSignOut,
@@ -247,7 +667,7 @@ function PermissionFilteredLink({ link }) {
247
667
  if (status.invisible || !status.permitted) {
248
668
  return null;
249
669
  }
250
- return /* @__PURE__ */ jsx7("a", { href: link.to, children: link.label });
670
+ return /* @__PURE__ */ jsx8("a", { href: link.to, children: link.label });
251
671
  }
252
672
  export {
253
673
  ActionButton,
@@ -257,6 +677,7 @@ export {
257
677
  AvatarWithInitials,
258
678
  BooleanField,
259
679
  BulkActionBar,
680
+ ConfirmContext,
260
681
  ConfirmProvider,
261
682
  DataTable,
262
683
  DateField,
@@ -282,6 +703,7 @@ export {
282
703
  TextField,
283
704
  ToastContext,
284
705
  UIPluginProvider,
706
+ UploadField,
285
707
  UrlField,
286
708
  UserMenu,
287
709
  createUIPlugin,
package/llms.txt CHANGED
@@ -74,11 +74,43 @@ useColumnInference(table: Record<string, unknown> | undefined, columns?: string[
74
74
  ```typescript
75
75
  DateField, BooleanField, NumberField, TextField,
76
76
  EmailField, UrlField, ImageField, FileField,
77
- RelationField, JsonField
77
+ RelationField, JsonField, UploadField
78
78
  fieldForColumn(columnMeta): ComponentType // maps Drizzle column type to field
79
79
  fieldsForTable(table): Record<string, ComponentType> // field map for whole table
80
80
  ```
81
81
 
82
+ ### UploadField (headless)
83
+
84
+ Controlled multi-file upload field built on `@cfast/storage`'s `POST
85
+ /uploads/:filetype` route. Unlike `DropZone` (which wraps a single
86
+ `useUpload` call), `UploadField` is form-friendly: it owns its own upload
87
+ state, enforces `maxFiles`, and reports successful uploads through the
88
+ controlled `value` / `onChange` pair.
89
+
90
+ ```tsx
91
+ import { UploadField } from "@cfast/ui";
92
+
93
+ <UploadField
94
+ filetype="productImages" // matches defineStorage key
95
+ basePath="/uploads" // matches storageRoutes() mount path
96
+ multiple // allow multiple files
97
+ maxFiles={10} // hard cap (default 1 when !multiple)
98
+ accept="image/*" // forwarded to <input accept>
99
+ maxSize={5 * 1024 * 1024} // optional client-side byte cap
100
+ value={keys} // string[] of R2 keys (controlled)
101
+ onChange={setKeys} // fires with updated keys
102
+ onError={(file) => ...} // per-file upload error callback
103
+ />
104
+ ```
105
+
106
+ Tests mock the POST endpoint by passing a custom `uploader` prop instead
107
+ of stubbing `XMLHttpRequest` globally. The `uploader` signature is:
108
+
109
+ ```ts
110
+ (file: File, opts: { url: string; onProgress: (pct: number) => void }) =>
111
+ Promise<{ key: string; size: number; type: string; url?: string }>
112
+ ```
113
+
82
114
  ### Plugin API
83
115
 
84
116
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfast/ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Permission-aware React components with UI library plugins",
5
5
  "keywords": [
6
6
  "cfast",
@@ -37,17 +37,35 @@
37
37
  "access": "public"
38
38
  },
39
39
  "peerDependencies": {
40
+ "@cfast/actions": ">=0.1.0 <0.3.0",
41
+ "@cfast/auth": ">=0.3.0 <0.5.0",
42
+ "@cfast/db": ">=0.3.0 <0.5.0",
43
+ "@cfast/pagination": ">=0.2.0 <0.4.0",
44
+ "@cfast/permissions": ">=0.3.0 <0.6.0",
45
+ "@cfast/storage": ">=0.1.0 <0.3.0",
40
46
  "react": ">=19",
41
47
  "react-dom": ">=19",
42
48
  "react-router": ">=7"
43
49
  },
44
- "dependencies": {
45
- "@cfast/actions": "0.1.1",
46
- "@cfast/db": "0.1.1",
47
- "@cfast/auth": "0.2.2",
48
- "@cfast/pagination": "0.1.0",
49
- "@cfast/permissions": "0.1.0",
50
- "@cfast/storage": "0.1.0"
50
+ "peerDependenciesMeta": {
51
+ "@cfast/actions": {
52
+ "optional": false
53
+ },
54
+ "@cfast/auth": {
55
+ "optional": false
56
+ },
57
+ "@cfast/db": {
58
+ "optional": false
59
+ },
60
+ "@cfast/pagination": {
61
+ "optional": false
62
+ },
63
+ "@cfast/permissions": {
64
+ "optional": false
65
+ },
66
+ "@cfast/storage": {
67
+ "optional": false
68
+ }
51
69
  },
52
70
  "devDependencies": {
53
71
  "@testing-library/react": "^16.3.0",
@@ -60,7 +78,13 @@
60
78
  "react-router": "^7.12.0",
61
79
  "tsup": "^8",
62
80
  "typescript": "^5.7",
63
- "vitest": "^4.1.0"
81
+ "vitest": "^4.1.0",
82
+ "@cfast/actions": "0.1.3",
83
+ "@cfast/auth": "0.4.2",
84
+ "@cfast/db": "0.4.1",
85
+ "@cfast/pagination": "0.2.0",
86
+ "@cfast/permissions": "0.5.1",
87
+ "@cfast/storage": "0.2.1"
64
88
  },
65
89
  "scripts": {
66
90
  "build": "tsup src/index.ts src/client.ts --format esm --dts",