@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.
- package/dist/{chunk-PWBG6CGF.js → chunk-TLLCSKE5.js} +1 -0
- package/dist/{client-CIx8_tmv.d.ts → client-DcCN1BR-.d.ts} +129 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +36 -3
- package/dist/index.js +445 -23
- package/llms.txt +33 -1
- package/package.json +33 -9
|
@@ -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 {
|
|
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 {
|
|
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
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
|
|
2
|
-
export {
|
|
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-
|
|
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
|
|
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__ */
|
|
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
|
|
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__ */
|
|
582
|
+
return /* @__PURE__ */ jsx7(Shell, { sidebar, header, children });
|
|
163
583
|
}
|
|
164
584
|
function AppShellSidebar({ items }) {
|
|
165
|
-
return /* @__PURE__ */
|
|
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__ */
|
|
589
|
+
return /* @__PURE__ */ jsx7(PermissionFilteredItem, { item });
|
|
170
590
|
}
|
|
171
|
-
return /* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
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__ */
|
|
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
|
|
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__ */
|
|
216
|
-
/* @__PURE__ */
|
|
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__ */
|
|
225
|
-
/* @__PURE__ */
|
|
226
|
-
/* @__PURE__ */
|
|
227
|
-
/* @__PURE__ */
|
|
228
|
-
/* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
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__ */
|
|
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__ */
|
|
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.
|
|
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
|
-
"
|
|
45
|
-
"@cfast/actions":
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"@cfast/
|
|
49
|
-
|
|
50
|
-
|
|
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",
|