@cfast/ui 0.2.0 → 0.3.0
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/LICENSE +21 -0
- package/dist/{client-CIx8_tmv.d.ts → client-C0K-7jLA.d.ts} +125 -1
- package/dist/client.d.ts +1 -1
- package/dist/index.d.ts +36 -3
- package/dist/index.js +442 -22
- package/llms.txt +38 -1
- package/package.json +41 -17
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Schmidt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -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
|
*
|
|
@@ -1881,4 +2005,4 @@ declare function ListView<T = unknown>({ title, data, table: _table, columns, ac
|
|
|
1881
2005
|
*/
|
|
1882
2006
|
declare function DetailView<T = unknown>({ title, table, record, fields: fieldsProp, exclude, breadcrumb, }: DetailViewProps<T>): react_jsx_runtime.JSX.Element;
|
|
1883
2007
|
|
|
1884
|
-
export {
|
|
2008
|
+
export { type FormStatusProps 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, DetailView as G, type DetailViewProps as H, type ImageFieldProps as I, type JsonFieldProps as J, DropZone as K, type DropZoneProps as L, type DropZoneSlotProps as M, type NumberFieldProps as N, FileList$1 as O, type FileListFile as P, type FileListProps as Q, type RelationFieldProps as R, FilterBar as S, type TextFieldProps as T, type UrlFieldProps as U, type FilterBarProps as V, type FilterDef as W, type FilterOption as X, type FilterType as Y, FormStatus as Z, type FormStatusData as _, type UploadFieldProps as a, ImagePreview as a0, type ImagePreviewProps as a1, ImpersonationBanner as a2, type ImpersonationBannerProps as a3, ListView as a4, type ListViewProps as a5, type PageContainerSlotProps as a6, PermissionGate as a7, type PermissionGateProps as a8, RoleBadge as a9, type RoleBadgeProps as aa, type SidebarSlotProps as ab, type TableCellSlotProps as ac, type TableRowSlotProps as ad, type TableSectionSlotProps as ae, type TableSlotProps as af, type ToastApi as ag, ToastContext as ah, type ToastOptions as ai, type ToastSlotProps as aj, type ToastType as ak, type TooltipSlotProps as al, type UIPlugin as am, type UIPluginComponents as an, UIPluginProvider as ao, type UploadFieldFile as ap, type UserMenuLink as aq, type WhenForbidden as ar, createUIPlugin as as, getInitials as at, useActionToast as au, useColumnInference as av, useComponent as aw, useConfirm as ax, useToast as ay, useUIPlugin 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, type ConfirmDialogSlotProps as v, type ConfirmOptions as w, ConfirmProvider as x, DataTable as y, type DataTableProps 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, x as ConfirmProvider, y as DataTable, G as DetailView, K as DropZone, O as FileList, S as FilterBar, Z as FormStatus, a0 as ImagePreview, a2 as ImpersonationBanner, a4 as ListView, a7 as PermissionGate, a9 as RoleBadge, ao as UIPluginProvider, as as createUIPlugin, at as getInitials, au as useActionToast, av as useColumnInference, aw as useComponent, ax as useConfirm, ay as useToast, az as useUIPlugin } from './client-C0K-7jLA.js';
|
|
2
2
|
import 'react/jsx-runtime';
|
|
3
3
|
import 'react';
|
|
4
4
|
import '@cfast/actions/client';
|
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-C0K-7jLA.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 ConfirmDialogSlotProps, w as ConfirmOptions, x as ConfirmProvider, y as DataTable, z as DataTableProps, G as DetailView, H as DetailViewProps, K as DropZone, L as DropZoneProps, M as DropZoneSlotProps, O as FileList, P as FileListFile, Q as FileListProps, S as FilterBar, V as FilterBarProps, W as FilterDef, X as FilterOption, Y as FilterType, Z as FormStatus, _ as FormStatusData, $ as FormStatusProps, a0 as ImagePreview, a1 as ImagePreviewProps, a2 as ImpersonationBanner, a3 as ImpersonationBannerProps, a4 as ListView, a5 as ListViewProps, a6 as PageContainerSlotProps, a7 as PermissionGate, a8 as PermissionGateProps, a9 as RoleBadge, aa as RoleBadgeProps, ab as SidebarSlotProps, ac as TableCellSlotProps, ad as TableRowSlotProps, ae as TableSectionSlotProps, af as TableSlotProps, ag as ToastApi, ah as ToastContext, ai as ToastOptions, aj as ToastSlotProps, ak as ToastType, al as TooltipSlotProps, am as UIPlugin, an as UIPluginComponents, ao as UIPluginProvider, ap as UploadFieldFile, aq as UserMenuLink, ar as WhenForbidden, as as createUIPlugin, at as getInitials, au as useActionToast, av as useColumnInference, aw as useComponent, ax as useConfirm, ay as useToast, az as useUIPlugin } from './client-C0K-7jLA.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
|
@@ -125,9 +125,428 @@ function RelationField({
|
|
|
125
125
|
return /* @__PURE__ */ jsx4("span", { children: displayValue });
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// src/fields/upload-field.tsx
|
|
129
|
+
import { useCallback, useId, useRef, useState } from "react";
|
|
130
|
+
import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
131
|
+
function defaultUploader(file, options) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const xhr = new XMLHttpRequest();
|
|
134
|
+
xhr.open("POST", options.url);
|
|
135
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
136
|
+
if (event.lengthComputable) {
|
|
137
|
+
options.onProgress(Math.round(event.loaded / event.total * 100));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
xhr.addEventListener("load", () => {
|
|
141
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(xhr.responseText);
|
|
144
|
+
if (typeof parsed.key === "string" && typeof parsed.size === "number" && typeof parsed.type === "string") {
|
|
145
|
+
resolve({
|
|
146
|
+
key: parsed.key,
|
|
147
|
+
size: parsed.size,
|
|
148
|
+
type: parsed.type,
|
|
149
|
+
url: typeof parsed.url === "string" ? parsed.url : void 0
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
reject(new Error("Invalid response from server"));
|
|
154
|
+
} catch {
|
|
155
|
+
reject(new Error("Invalid response from server"));
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(xhr.responseText);
|
|
160
|
+
const detail = typeof parsed.detail === "string" ? parsed.detail : typeof parsed.message === "string" ? parsed.message : `Upload failed (${xhr.status})`;
|
|
161
|
+
reject(new Error(detail));
|
|
162
|
+
} catch {
|
|
163
|
+
reject(new Error(`Upload failed (${xhr.status})`));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
xhr.addEventListener("error", () => {
|
|
168
|
+
reject(new Error("Network error during upload"));
|
|
169
|
+
});
|
|
170
|
+
if (options.signal) {
|
|
171
|
+
options.signal.addEventListener("abort", () => {
|
|
172
|
+
xhr.abort();
|
|
173
|
+
reject(new Error("Upload cancelled"));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const formData = new FormData();
|
|
177
|
+
formData.append("file", file);
|
|
178
|
+
xhr.send(formData);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function matchesAccept(file, accept) {
|
|
182
|
+
if (!accept) return true;
|
|
183
|
+
const tokens = accept.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
184
|
+
if (tokens.length === 0) return true;
|
|
185
|
+
const fileType = (file.type || "").toLowerCase();
|
|
186
|
+
const fileName = file.name.toLowerCase();
|
|
187
|
+
for (const token of tokens) {
|
|
188
|
+
if (token.startsWith(".")) {
|
|
189
|
+
if (fileName.endsWith(token)) return true;
|
|
190
|
+
} else if (token.endsWith("/*")) {
|
|
191
|
+
const prefix = token.slice(0, -1);
|
|
192
|
+
if (fileType.startsWith(prefix)) return true;
|
|
193
|
+
} else if (token === fileType) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
function formatBytes2(bytes) {
|
|
200
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
201
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
202
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
203
|
+
}
|
|
204
|
+
function UploadField({
|
|
205
|
+
label,
|
|
206
|
+
className,
|
|
207
|
+
filetype,
|
|
208
|
+
basePath = "/uploads",
|
|
209
|
+
multiple = false,
|
|
210
|
+
maxFiles,
|
|
211
|
+
accept,
|
|
212
|
+
maxSize,
|
|
213
|
+
value,
|
|
214
|
+
onChange,
|
|
215
|
+
onError,
|
|
216
|
+
disabled = false,
|
|
217
|
+
uploader = defaultUploader
|
|
218
|
+
}) {
|
|
219
|
+
const [files, setFiles] = useState([]);
|
|
220
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
221
|
+
const [globalError, setGlobalError] = useState(null);
|
|
222
|
+
const inputRef = useRef(null);
|
|
223
|
+
const reactId = useId();
|
|
224
|
+
const normalizedBase = `/${basePath.replace(/^\/+|\/+$/g, "")}`;
|
|
225
|
+
const uploadUrl = `${normalizedBase}/${filetype}`;
|
|
226
|
+
const effectiveMax = maxFiles ?? (multiple ? Number.POSITIVE_INFINITY : 1);
|
|
227
|
+
const currentValue = value ?? [];
|
|
228
|
+
const appendKey = useCallback(
|
|
229
|
+
(key) => {
|
|
230
|
+
const next = [...currentValue, key];
|
|
231
|
+
onChange?.(next);
|
|
232
|
+
},
|
|
233
|
+
[currentValue, onChange]
|
|
234
|
+
);
|
|
235
|
+
const removeKey = useCallback(
|
|
236
|
+
(key) => {
|
|
237
|
+
const next = currentValue.filter((k) => k !== key);
|
|
238
|
+
onChange?.(next);
|
|
239
|
+
},
|
|
240
|
+
[currentValue, onChange]
|
|
241
|
+
);
|
|
242
|
+
const removeTrackerEntry = useCallback((id) => {
|
|
243
|
+
setFiles((prev) => prev.filter((f) => f.id !== id));
|
|
244
|
+
}, []);
|
|
245
|
+
const handleFiles = useCallback(
|
|
246
|
+
async (incoming) => {
|
|
247
|
+
if (disabled) return;
|
|
248
|
+
setGlobalError(null);
|
|
249
|
+
const list = Array.from(incoming);
|
|
250
|
+
if (list.length === 0) return;
|
|
251
|
+
const inFlight = files.filter((f) => f.status !== "error").length;
|
|
252
|
+
const remainingSlots = effectiveMax - currentValue.length - inFlight;
|
|
253
|
+
if (remainingSlots <= 0) {
|
|
254
|
+
setGlobalError(
|
|
255
|
+
`Maximum of ${effectiveMax} file${effectiveMax === 1 ? "" : "s"} reached`
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (list.length > remainingSlots) {
|
|
260
|
+
setGlobalError(
|
|
261
|
+
`Only ${remainingSlots} more file${remainingSlots === 1 ? "" : "s"} allowed`
|
|
262
|
+
);
|
|
263
|
+
list.length = remainingSlots;
|
|
264
|
+
}
|
|
265
|
+
const accepted = [];
|
|
266
|
+
for (const file of list) {
|
|
267
|
+
const id = `${reactId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
268
|
+
if (!matchesAccept(file, accept)) {
|
|
269
|
+
const errEntry = {
|
|
270
|
+
id,
|
|
271
|
+
name: file.name,
|
|
272
|
+
size: file.size,
|
|
273
|
+
type: file.type,
|
|
274
|
+
progress: 0,
|
|
275
|
+
status: "error",
|
|
276
|
+
error: `${file.type || "file"} is not an accepted type`
|
|
277
|
+
};
|
|
278
|
+
accepted.push({ entry: errEntry, file });
|
|
279
|
+
onError?.(errEntry);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (maxSize != null && file.size > maxSize) {
|
|
283
|
+
const errEntry = {
|
|
284
|
+
id,
|
|
285
|
+
name: file.name,
|
|
286
|
+
size: file.size,
|
|
287
|
+
type: file.type,
|
|
288
|
+
progress: 0,
|
|
289
|
+
status: "error",
|
|
290
|
+
error: `File is ${formatBytes2(file.size)} but max is ${formatBytes2(maxSize)}`
|
|
291
|
+
};
|
|
292
|
+
accepted.push({ entry: errEntry, file });
|
|
293
|
+
onError?.(errEntry);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
accepted.push({
|
|
297
|
+
entry: {
|
|
298
|
+
id,
|
|
299
|
+
name: file.name,
|
|
300
|
+
size: file.size,
|
|
301
|
+
type: file.type,
|
|
302
|
+
progress: 0,
|
|
303
|
+
status: "pending"
|
|
304
|
+
},
|
|
305
|
+
file
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
setFiles((prev) => [...prev, ...accepted.map((a) => a.entry)]);
|
|
309
|
+
for (const { entry, file } of accepted) {
|
|
310
|
+
if (entry.status === "error") continue;
|
|
311
|
+
setFiles(
|
|
312
|
+
(prev) => prev.map(
|
|
313
|
+
(f) => f.id === entry.id ? { ...f, status: "uploading" } : f
|
|
314
|
+
)
|
|
315
|
+
);
|
|
316
|
+
try {
|
|
317
|
+
const result = await uploader(file, {
|
|
318
|
+
url: uploadUrl,
|
|
319
|
+
onProgress: (percent) => {
|
|
320
|
+
setFiles(
|
|
321
|
+
(prev) => prev.map(
|
|
322
|
+
(f) => f.id === entry.id ? { ...f, progress: percent } : f
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
setFiles(
|
|
328
|
+
(prev) => prev.map(
|
|
329
|
+
(f) => f.id === entry.id ? { ...f, status: "success", progress: 100, key: result.key } : f
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
appendKey(result.key);
|
|
333
|
+
setFiles((prev) => prev.filter((f) => f.id !== entry.id));
|
|
334
|
+
} catch (e) {
|
|
335
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
336
|
+
const errEntry = {
|
|
337
|
+
...entry,
|
|
338
|
+
status: "error",
|
|
339
|
+
error: message
|
|
340
|
+
};
|
|
341
|
+
setFiles(
|
|
342
|
+
(prev) => prev.map((f) => f.id === entry.id ? errEntry : f)
|
|
343
|
+
);
|
|
344
|
+
onError?.(errEntry);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
[
|
|
349
|
+
accept,
|
|
350
|
+
appendKey,
|
|
351
|
+
currentValue.length,
|
|
352
|
+
disabled,
|
|
353
|
+
effectiveMax,
|
|
354
|
+
files,
|
|
355
|
+
maxSize,
|
|
356
|
+
onError,
|
|
357
|
+
reactId,
|
|
358
|
+
uploadUrl,
|
|
359
|
+
uploader
|
|
360
|
+
]
|
|
361
|
+
);
|
|
362
|
+
const handleDrop = useCallback(
|
|
363
|
+
(e) => {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
setIsDragOver(false);
|
|
366
|
+
void handleFiles(e.dataTransfer.files);
|
|
367
|
+
},
|
|
368
|
+
[handleFiles]
|
|
369
|
+
);
|
|
370
|
+
const handleDragOver = useCallback(
|
|
371
|
+
(e) => {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
if (!disabled) setIsDragOver(true);
|
|
374
|
+
},
|
|
375
|
+
[disabled]
|
|
376
|
+
);
|
|
377
|
+
const handleDragLeave = useCallback(() => {
|
|
378
|
+
setIsDragOver(false);
|
|
379
|
+
}, []);
|
|
380
|
+
const handleClick = useCallback(() => {
|
|
381
|
+
if (disabled) return;
|
|
382
|
+
inputRef.current?.click();
|
|
383
|
+
}, [disabled]);
|
|
384
|
+
const handleInputChange = useCallback(
|
|
385
|
+
(e) => {
|
|
386
|
+
void handleFiles(e.target.files ?? []);
|
|
387
|
+
e.target.value = "";
|
|
388
|
+
},
|
|
389
|
+
[handleFiles]
|
|
390
|
+
);
|
|
391
|
+
return /* @__PURE__ */ jsxs2("div", { className, "data-cfast-upload-field": filetype, children: [
|
|
392
|
+
label != null ? /* @__PURE__ */ jsx5(
|
|
393
|
+
"label",
|
|
394
|
+
{
|
|
395
|
+
htmlFor: `${reactId}-input`,
|
|
396
|
+
style: { display: "block", marginBottom: 4 },
|
|
397
|
+
children: label
|
|
398
|
+
}
|
|
399
|
+
) : null,
|
|
400
|
+
/* @__PURE__ */ jsx5(
|
|
401
|
+
"div",
|
|
402
|
+
{
|
|
403
|
+
role: "button",
|
|
404
|
+
tabIndex: disabled ? -1 : 0,
|
|
405
|
+
"aria-disabled": disabled,
|
|
406
|
+
"data-drag-over": isDragOver,
|
|
407
|
+
"data-testid": "upload-field-drop-zone",
|
|
408
|
+
onClick: handleClick,
|
|
409
|
+
onDrop: handleDrop,
|
|
410
|
+
onDragOver: handleDragOver,
|
|
411
|
+
onDragLeave: handleDragLeave,
|
|
412
|
+
onKeyDown: (e) => {
|
|
413
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
414
|
+
e.preventDefault();
|
|
415
|
+
handleClick();
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
style: {
|
|
419
|
+
border: "2px dashed",
|
|
420
|
+
borderColor: isDragOver ? "#1976d2" : "#ccc",
|
|
421
|
+
borderRadius: 4,
|
|
422
|
+
padding: 16,
|
|
423
|
+
textAlign: "center",
|
|
424
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
425
|
+
opacity: disabled ? 0.6 : 1
|
|
426
|
+
},
|
|
427
|
+
children: multiple ? "Drop files here or click to browse" : "Drop a file here or click to browse"
|
|
428
|
+
}
|
|
429
|
+
),
|
|
430
|
+
/* @__PURE__ */ jsx5(
|
|
431
|
+
"input",
|
|
432
|
+
{
|
|
433
|
+
ref: inputRef,
|
|
434
|
+
id: `${reactId}-input`,
|
|
435
|
+
type: "file",
|
|
436
|
+
accept,
|
|
437
|
+
multiple,
|
|
438
|
+
disabled,
|
|
439
|
+
style: { display: "none" },
|
|
440
|
+
onChange: handleInputChange,
|
|
441
|
+
"data-testid": "upload-field-input"
|
|
442
|
+
}
|
|
443
|
+
),
|
|
444
|
+
globalError != null ? /* @__PURE__ */ jsx5("div", { role: "alert", "data-testid": "upload-field-global-error", style: { color: "#d32f2f", marginTop: 8 }, children: globalError }) : null,
|
|
445
|
+
currentValue.length > 0 ? /* @__PURE__ */ jsx5(
|
|
446
|
+
"ul",
|
|
447
|
+
{
|
|
448
|
+
"data-testid": "upload-field-value-list",
|
|
449
|
+
style: { listStyle: "none", padding: 0, margin: "8px 0 0 0" },
|
|
450
|
+
children: currentValue.map((key) => /* @__PURE__ */ jsxs2(
|
|
451
|
+
"li",
|
|
452
|
+
{
|
|
453
|
+
"data-testid": "upload-field-value-item",
|
|
454
|
+
style: {
|
|
455
|
+
display: "flex",
|
|
456
|
+
alignItems: "center",
|
|
457
|
+
gap: 8,
|
|
458
|
+
padding: "4px 0"
|
|
459
|
+
},
|
|
460
|
+
children: [
|
|
461
|
+
/* @__PURE__ */ jsx5("span", { style: { flex: 1, fontSize: "0.85em" }, children: key }),
|
|
462
|
+
/* @__PURE__ */ jsx5(
|
|
463
|
+
"button",
|
|
464
|
+
{
|
|
465
|
+
type: "button",
|
|
466
|
+
onClick: () => removeKey(key),
|
|
467
|
+
disabled,
|
|
468
|
+
"aria-label": `Remove ${key}`,
|
|
469
|
+
style: {
|
|
470
|
+
background: "none",
|
|
471
|
+
border: "none",
|
|
472
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
473
|
+
color: "#d32f2f"
|
|
474
|
+
},
|
|
475
|
+
children: "\xD7"
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
]
|
|
479
|
+
},
|
|
480
|
+
key
|
|
481
|
+
))
|
|
482
|
+
}
|
|
483
|
+
) : null,
|
|
484
|
+
files.length > 0 ? /* @__PURE__ */ jsx5(
|
|
485
|
+
"ul",
|
|
486
|
+
{
|
|
487
|
+
"data-testid": "upload-field-tracker",
|
|
488
|
+
style: { listStyle: "none", padding: 0, margin: "8px 0 0 0" },
|
|
489
|
+
children: files.map((file) => /* @__PURE__ */ jsxs2(
|
|
490
|
+
"li",
|
|
491
|
+
{
|
|
492
|
+
"data-testid": "upload-field-tracker-item",
|
|
493
|
+
"data-status": file.status,
|
|
494
|
+
style: {
|
|
495
|
+
display: "flex",
|
|
496
|
+
flexDirection: "column",
|
|
497
|
+
gap: 4,
|
|
498
|
+
padding: "4px 0"
|
|
499
|
+
},
|
|
500
|
+
children: [
|
|
501
|
+
/* @__PURE__ */ jsxs2("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [
|
|
502
|
+
/* @__PURE__ */ jsx5("span", { style: { flex: 1 }, children: file.name }),
|
|
503
|
+
/* @__PURE__ */ jsx5("span", { style: { color: "#666", fontSize: "0.85em" }, children: formatBytes2(file.size) }),
|
|
504
|
+
/* @__PURE__ */ jsx5(
|
|
505
|
+
"button",
|
|
506
|
+
{
|
|
507
|
+
type: "button",
|
|
508
|
+
onClick: () => removeTrackerEntry(file.id),
|
|
509
|
+
"aria-label": `Dismiss ${file.name}`,
|
|
510
|
+
style: {
|
|
511
|
+
background: "none",
|
|
512
|
+
border: "none",
|
|
513
|
+
cursor: "pointer",
|
|
514
|
+
color: "#666"
|
|
515
|
+
},
|
|
516
|
+
children: "\xD7"
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
] }),
|
|
520
|
+
file.status === "uploading" ? /* @__PURE__ */ jsx5(
|
|
521
|
+
"progress",
|
|
522
|
+
{
|
|
523
|
+
"data-testid": "upload-field-progress",
|
|
524
|
+
value: file.progress,
|
|
525
|
+
max: 100,
|
|
526
|
+
style: { width: "100%" }
|
|
527
|
+
}
|
|
528
|
+
) : null,
|
|
529
|
+
file.status === "error" && file.error != null ? /* @__PURE__ */ jsx5(
|
|
530
|
+
"div",
|
|
531
|
+
{
|
|
532
|
+
role: "alert",
|
|
533
|
+
"data-testid": "upload-field-file-error",
|
|
534
|
+
style: { color: "#d32f2f", fontSize: "0.85em" },
|
|
535
|
+
children: file.error
|
|
536
|
+
}
|
|
537
|
+
) : null
|
|
538
|
+
]
|
|
539
|
+
},
|
|
540
|
+
file.id
|
|
541
|
+
))
|
|
542
|
+
}
|
|
543
|
+
) : null
|
|
544
|
+
] });
|
|
545
|
+
}
|
|
546
|
+
|
|
128
547
|
// src/components/navigation-progress.tsx
|
|
129
548
|
import { useNavigation } from "react-router";
|
|
130
|
-
import { jsx as
|
|
549
|
+
import { jsx as jsx6 } from "react/jsx-runtime";
|
|
131
550
|
function NavigationProgress({
|
|
132
551
|
color = "#1976d2"
|
|
133
552
|
}) {
|
|
@@ -136,7 +555,7 @@ function NavigationProgress({
|
|
|
136
555
|
if (!isNavigating) {
|
|
137
556
|
return null;
|
|
138
557
|
}
|
|
139
|
-
return /* @__PURE__ */
|
|
558
|
+
return /* @__PURE__ */ jsx6(
|
|
140
559
|
"div",
|
|
141
560
|
{
|
|
142
561
|
role: "progressbar",
|
|
@@ -156,32 +575,32 @@ function NavigationProgress({
|
|
|
156
575
|
}
|
|
157
576
|
|
|
158
577
|
// src/components/app-shell.tsx
|
|
159
|
-
import { Fragment, jsx as
|
|
578
|
+
import { Fragment, jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
160
579
|
function AppShell({ children, sidebar, header }) {
|
|
161
580
|
const Shell = useComponent("appShell");
|
|
162
|
-
return /* @__PURE__ */
|
|
581
|
+
return /* @__PURE__ */ jsx7(Shell, { sidebar, header, children });
|
|
163
582
|
}
|
|
164
583
|
function AppShellSidebar({ items }) {
|
|
165
|
-
return /* @__PURE__ */
|
|
584
|
+
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
585
|
}
|
|
167
586
|
function SidebarItem({ item }) {
|
|
168
587
|
if (item.action) {
|
|
169
|
-
return /* @__PURE__ */
|
|
588
|
+
return /* @__PURE__ */ jsx7(PermissionFilteredItem, { item });
|
|
170
589
|
}
|
|
171
|
-
return /* @__PURE__ */
|
|
590
|
+
return /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsx7("a", { href: item.to, children: item.label }) });
|
|
172
591
|
}
|
|
173
592
|
function PermissionFilteredItem({ item }) {
|
|
174
593
|
const status = useActionStatus(item.action);
|
|
175
594
|
if (status.invisible || !status.permitted) {
|
|
176
595
|
return null;
|
|
177
596
|
}
|
|
178
|
-
return /* @__PURE__ */
|
|
597
|
+
return /* @__PURE__ */ jsx7("li", { children: /* @__PURE__ */ jsx7("a", { href: item.to, children: item.label }) });
|
|
179
598
|
}
|
|
180
599
|
function AppShellHeader({
|
|
181
600
|
children,
|
|
182
601
|
userMenu
|
|
183
602
|
}) {
|
|
184
|
-
return /* @__PURE__ */
|
|
603
|
+
return /* @__PURE__ */ jsxs3(
|
|
185
604
|
"header",
|
|
186
605
|
{
|
|
187
606
|
style: {
|
|
@@ -192,7 +611,7 @@ function AppShellHeader({
|
|
|
192
611
|
borderBottom: "1px solid #ddd"
|
|
193
612
|
},
|
|
194
613
|
children: [
|
|
195
|
-
children ?? /* @__PURE__ */
|
|
614
|
+
children ?? /* @__PURE__ */ jsx7(Fragment, {}),
|
|
196
615
|
userMenu ?? null
|
|
197
616
|
]
|
|
198
617
|
}
|
|
@@ -203,7 +622,7 @@ AppShell.Header = AppShellHeader;
|
|
|
203
622
|
|
|
204
623
|
// src/components/user-menu.tsx
|
|
205
624
|
import { useCurrentUser } from "@cfast/auth/client";
|
|
206
|
-
import { jsx as
|
|
625
|
+
import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
207
626
|
function UserMenu({
|
|
208
627
|
links = [],
|
|
209
628
|
onSignOut
|
|
@@ -212,8 +631,8 @@ function UserMenu({
|
|
|
212
631
|
if (!user) {
|
|
213
632
|
return null;
|
|
214
633
|
}
|
|
215
|
-
return /* @__PURE__ */
|
|
216
|
-
/* @__PURE__ */
|
|
634
|
+
return /* @__PURE__ */ jsxs4("div", { style: { position: "relative" }, children: [
|
|
635
|
+
/* @__PURE__ */ jsx8(
|
|
217
636
|
AvatarWithInitials,
|
|
218
637
|
{
|
|
219
638
|
src: user.avatarUrl,
|
|
@@ -221,17 +640,17 @@ function UserMenu({
|
|
|
221
640
|
size: "sm"
|
|
222
641
|
}
|
|
223
642
|
),
|
|
224
|
-
/* @__PURE__ */
|
|
225
|
-
/* @__PURE__ */
|
|
226
|
-
/* @__PURE__ */
|
|
227
|
-
/* @__PURE__ */
|
|
228
|
-
/* @__PURE__ */
|
|
643
|
+
/* @__PURE__ */ jsxs4("div", { className: "user-menu-dropdown", children: [
|
|
644
|
+
/* @__PURE__ */ jsxs4("div", { children: [
|
|
645
|
+
/* @__PURE__ */ jsx8("strong", { children: user.name }),
|
|
646
|
+
/* @__PURE__ */ jsx8("br", {}),
|
|
647
|
+
/* @__PURE__ */ jsx8("small", { children: user.email })
|
|
229
648
|
] }),
|
|
230
|
-
user.roles.length > 0 ? /* @__PURE__ */
|
|
649
|
+
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
650
|
links.map(
|
|
232
|
-
(link) => link.action ? /* @__PURE__ */
|
|
651
|
+
(link) => link.action ? /* @__PURE__ */ jsx8(PermissionFilteredLink, { link }, link.to) : /* @__PURE__ */ jsx8("a", { href: link.to, children: link.label }, link.to)
|
|
233
652
|
),
|
|
234
|
-
onSignOut ? /* @__PURE__ */
|
|
653
|
+
onSignOut ? /* @__PURE__ */ jsx8(
|
|
235
654
|
"button",
|
|
236
655
|
{
|
|
237
656
|
onClick: onSignOut,
|
|
@@ -247,7 +666,7 @@ function PermissionFilteredLink({ link }) {
|
|
|
247
666
|
if (status.invisible || !status.permitted) {
|
|
248
667
|
return null;
|
|
249
668
|
}
|
|
250
|
-
return /* @__PURE__ */
|
|
669
|
+
return /* @__PURE__ */ jsx8("a", { href: link.to, children: link.label });
|
|
251
670
|
}
|
|
252
671
|
export {
|
|
253
672
|
ActionButton,
|
|
@@ -282,6 +701,7 @@ export {
|
|
|
282
701
|
TextField,
|
|
283
702
|
ToastContext,
|
|
284
703
|
UIPluginProvider,
|
|
704
|
+
UploadField,
|
|
285
705
|
UrlField,
|
|
286
706
|
UserMenu,
|
|
287
707
|
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
|
|
@@ -157,3 +189,8 @@ useActionToast(composed.client, {
|
|
|
157
189
|
- Forgetting `<ConfirmProvider>` -- `useConfirm()` and `ActionButton` with `confirmation` need it.
|
|
158
190
|
- Passing raw data arrays to `DataTable` instead of a pagination result from `usePagination()`.
|
|
159
191
|
- Using `whenForbidden="show"` on `ActionButton` when you mean `"disable"` -- `"show"` renders even when forbidden with no visual indicator.
|
|
192
|
+
|
|
193
|
+
## See Also
|
|
194
|
+
|
|
195
|
+
- `@cfast/joy` -- Drop-in MUI Joy plugin that styles `@cfast/ui` headless components.
|
|
196
|
+
- `@cfast/actions` -- Provides the action descriptors consumed by `ActionButton`/`ActionForm`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfast/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Permission-aware React components with UI library plugins",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cfast",
|
|
@@ -36,25 +36,36 @@
|
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
|
-
"scripts": {
|
|
40
|
-
"build": "tsup src/index.ts src/client.ts --format esm --dts",
|
|
41
|
-
"dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
|
|
42
|
-
"typecheck": "tsc --noEmit",
|
|
43
|
-
"lint": "eslint src/",
|
|
44
|
-
"test": "vitest run"
|
|
45
|
-
},
|
|
46
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.5.0",
|
|
45
|
+
"@cfast/storage": ">=0.1.0 <0.3.0",
|
|
47
46
|
"react": ">=19",
|
|
48
47
|
"react-dom": ">=19",
|
|
49
48
|
"react-router": ">=7"
|
|
50
49
|
},
|
|
51
|
-
"
|
|
52
|
-
"@cfast/actions":
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"@cfast/
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
69
|
},
|
|
59
70
|
"devDependencies": {
|
|
60
71
|
"@testing-library/react": "^16.3.0",
|
|
@@ -67,6 +78,19 @@
|
|
|
67
78
|
"react-router": "^7.12.0",
|
|
68
79
|
"tsup": "^8",
|
|
69
80
|
"typescript": "^5.7",
|
|
70
|
-
"vitest": "^4.1.0"
|
|
81
|
+
"vitest": "^4.1.0",
|
|
82
|
+
"@cfast/actions": "0.1.2",
|
|
83
|
+
"@cfast/auth": "0.4.0",
|
|
84
|
+
"@cfast/pagination": "0.2.0",
|
|
85
|
+
"@cfast/permissions": "0.4.0",
|
|
86
|
+
"@cfast/storage": "0.2.0",
|
|
87
|
+
"@cfast/db": "0.4.0"
|
|
88
|
+
},
|
|
89
|
+
"scripts": {
|
|
90
|
+
"build": "tsup src/index.ts src/client.ts --format esm --dts",
|
|
91
|
+
"dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
|
|
92
|
+
"typecheck": "tsc --noEmit",
|
|
93
|
+
"lint": "eslint src/",
|
|
94
|
+
"test": "vitest run"
|
|
71
95
|
}
|
|
72
|
-
}
|
|
96
|
+
}
|