gbs-add-block 1.2.12 → 1.2.13

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.
@@ -0,0 +1,83 @@
1
+ import type { RejectionCode } from "./types";
2
+
3
+ export interface ValidationRules {
4
+ /** Same syntax as `<input accept>`: `.pdf`, `image/*`, `application/json`. */
5
+ accept?: string;
6
+ /** Bytes. */
7
+ maxSize?: number;
8
+ /** Bytes. */
9
+ minSize?: number;
10
+ /** Total files, counting ones already selected. Ignored when `multiple` is false. */
11
+ maxFiles?: number;
12
+ /** When false, a new file replaces the current one. Default false. */
13
+ multiple?: boolean;
14
+ /** Keep a file that matches one already selected (same name, size and date). */
15
+ allowDuplicates?: boolean;
16
+ /** Return a message to reject the file. */
17
+ validate?(file: File): string | null | undefined;
18
+ }
19
+
20
+ /** Whether a file matches an `accept` string, the way the browser's picker reads it. */
21
+ export function matchesAccept(file: { name: string; type: string }, accept?: string): boolean {
22
+ const tokens = (accept ?? "")
23
+ .split(",")
24
+ .map((token) => token.trim().toLowerCase())
25
+ .filter(Boolean);
26
+ if (tokens.length === 0) return true;
27
+
28
+ const name = file.name.toLowerCase();
29
+ const type = file.type.toLowerCase();
30
+ return tokens.some((token) => {
31
+ if (token.startsWith(".")) return name.endsWith(token);
32
+ if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
33
+ return type === token;
34
+ });
35
+ }
36
+
37
+ /** Two picks of the same file on disk produce the same key. */
38
+ export const fileKey = (file: File) => `${file.name}:${file.size}:${file.lastModified}`;
39
+
40
+ export interface Partitioned {
41
+ accepted: File[];
42
+ rejected: { file: File; code: RejectionCode; message?: string }[];
43
+ }
44
+
45
+ function check(file: File, rules: ValidationRules): { code: RejectionCode; message?: string } | null {
46
+ if (!matchesAccept(file, rules.accept)) return { code: "file-type" };
47
+ if (rules.maxSize !== undefined && file.size > rules.maxSize) return { code: "file-too-large" };
48
+ if (rules.minSize !== undefined && file.size < rules.minSize) return { code: "file-too-small" };
49
+ const message = rules.validate?.(file);
50
+ if (message) return { code: "custom", message };
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Splits a new selection into files to keep and files to reject, with a reason
56
+ * for each. In single mode the first valid file will replace the current one.
57
+ */
58
+ export function partitionFiles(
59
+ incoming: readonly File[],
60
+ existing: readonly File[],
61
+ rules: ValidationRules = {},
62
+ ): Partitioned {
63
+ const multiple = rules.multiple ?? false;
64
+ const seen = new Set(multiple ? existing.map(fileKey) : []);
65
+ let room = multiple ? (rules.maxFiles ?? Infinity) - existing.length : 1;
66
+ const result: Partitioned = { accepted: [], rejected: [] };
67
+
68
+ for (const file of incoming) {
69
+ const failure = check(file, rules);
70
+ if (failure) {
71
+ result.rejected.push({ file, ...failure });
72
+ } else if (!rules.allowDuplicates && seen.has(fileKey(file))) {
73
+ result.rejected.push({ file, code: "duplicate" });
74
+ } else if (room <= 0) {
75
+ result.rejected.push({ file, code: "too-many-files" });
76
+ } else {
77
+ result.accepted.push(file);
78
+ seen.add(fileKey(file));
79
+ room--;
80
+ }
81
+ }
82
+ return result;
83
+ }
@@ -0,0 +1,7 @@
1
+ export { FileUploader } from "./react/FileUploader";
2
+ export type { FileUploaderProps } from "./react/FileUploader";
3
+ export { useFileUploader } from "./react/useFileUploader";
4
+ export type { UseFileUploaderResult } from "./react/useFileUploader";
5
+ export { defaultUploaderText } from "./react/locale";
6
+ export type { FileUploaderHandle, UploaderSlot } from "./react/props";
7
+ export * from "./core";
@@ -0,0 +1,268 @@
1
+ "use client";
2
+
3
+ import { memo, useCallback, type ReactNode } from "react";
4
+ import { fileKind, formatBytes } from "../core/format";
5
+ import type { UploaderStore } from "../core/store";
6
+ import type { ExistingFile, FileKind, UploaderLocaleText, UploadItem } from "../core/types";
7
+ import {
8
+ DownloadIcon,
9
+ FileKindIcon,
10
+ PauseIcon,
11
+ PlayIcon,
12
+ RetryIcon,
13
+ XIcon,
14
+ } from "./icons";
15
+ import { cx, type UploaderSlot } from "./props";
16
+
17
+ const percentFormats = new Map<string, Intl.NumberFormat>();
18
+
19
+ function formatPercent(value: number, locale?: string) {
20
+ const key = locale ?? "";
21
+ let format = percentFormats.get(key);
22
+ if (!format) {
23
+ format = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 });
24
+ percentFormats.set(key, format);
25
+ }
26
+ return format.format(value);
27
+ }
28
+
29
+ interface SharedRowProps {
30
+ preview: boolean;
31
+ removable: boolean;
32
+ disabled: boolean;
33
+ locale?: string;
34
+ text: UploaderLocaleText;
35
+ classNames?: Partial<Record<UploaderSlot, string>>;
36
+ }
37
+
38
+ function IconButton({
39
+ label,
40
+ disabled,
41
+ onClick,
42
+ children,
43
+ }: {
44
+ label: string;
45
+ disabled: boolean;
46
+ onClick(): void;
47
+ children: ReactNode;
48
+ }) {
49
+ return (
50
+ <button
51
+ type="button"
52
+ className="fu-icon-button"
53
+ aria-label={label}
54
+ title={label}
55
+ disabled={disabled}
56
+ onClick={onClick}
57
+ >
58
+ {children}
59
+ </button>
60
+ );
61
+ }
62
+
63
+ function Thumb({
64
+ kind,
65
+ className,
66
+ children,
67
+ }: {
68
+ kind: FileKind;
69
+ className?: string;
70
+ children?: ReactNode;
71
+ }) {
72
+ return (
73
+ <span className={cx("fu-thumb", className)} data-kind={kind} aria-hidden="true">
74
+ <FileKindIcon kind={kind} />
75
+ {children}
76
+ </span>
77
+ );
78
+ }
79
+
80
+ interface FileRowProps extends SharedRowProps {
81
+ item: UploadItem;
82
+ store: UploaderStore;
83
+ canUpload: boolean;
84
+ }
85
+
86
+ /** One selected file. Memoized: a progress tick re-renders only its own row. */
87
+ export const FileRow = memo(function FileRow({
88
+ item,
89
+ store,
90
+ canUpload,
91
+ preview,
92
+ removable,
93
+ disabled,
94
+ locale,
95
+ text,
96
+ classNames,
97
+ }: FileRowProps) {
98
+ const { file, status } = item;
99
+ const kind = fileKind(file);
100
+ const name = file.name;
101
+
102
+ // React 19 ref cleanup: the object URL lives exactly as long as the <img>,
103
+ // and unlike a base64 data URL it costs no memory copy of the file.
104
+ const attachPreview = useCallback(
105
+ (img: HTMLImageElement | null) => {
106
+ if (!img) return;
107
+ const url = URL.createObjectURL(file);
108
+ img.src = url;
109
+ return () => URL.revokeObjectURL(url);
110
+ },
111
+ [file],
112
+ );
113
+
114
+ const percent = formatPercent(item.progress, locale);
115
+ const size = formatBytes(file.size, locale);
116
+ const label: Record<UploadItem["status"], string | null> = {
117
+ idle: null,
118
+ queued: text.queued,
119
+ uploading: null,
120
+ paused: `${text.paused} · ${percent}`,
121
+ success: text.uploaded,
122
+ error: text.failed,
123
+ canceled: text.canceled,
124
+ };
125
+ const meta =
126
+ status === "uploading"
127
+ ? text.progress(formatBytes(item.uploadedBytes, locale), size, percent)
128
+ : [size, label[status]].filter(Boolean).join(" · ");
129
+
130
+ const showProgress =
131
+ status === "uploading" || status === "paused" || (status === "error" && item.progress > 0);
132
+ const inFlight = status === "uploading" || status === "queued";
133
+
134
+ return (
135
+ <li className={cx("fu-item", classNames?.item)} data-status={status}>
136
+ <Thumb kind={kind} className={classNames?.thumb}>
137
+ {preview && kind === "image" && (
138
+ <img
139
+ ref={attachPreview}
140
+ alt=""
141
+ decoding="async"
142
+ // Formats the browser can't draw (e.g. HEIC) fall back to the icon underneath.
143
+ onError={(event) => {
144
+ event.currentTarget.hidden = true;
145
+ }}
146
+ />
147
+ )}
148
+ </Thumb>
149
+
150
+ <div className="fu-body">
151
+ <span className="fu-name" title={name}>
152
+ {name}
153
+ </span>
154
+ <span className="fu-meta">{meta}</span>
155
+ {status === "error" && item.error && <span className="fu-item-error">{item.error}</span>}
156
+ {showProgress && (
157
+ <div
158
+ className={cx("fu-progress", classNames?.progress)}
159
+ role="progressbar"
160
+ aria-label={name}
161
+ aria-valuemin={0}
162
+ aria-valuemax={100}
163
+ aria-valuenow={Math.round(item.progress * 100)}
164
+ >
165
+ <span className="fu-progress-bar" style={{ transform: `scaleX(${item.progress})` }} />
166
+ </div>
167
+ )}
168
+ </div>
169
+
170
+ <div className={cx("fu-actions", classNames?.actions)}>
171
+ {canUpload && status === "uploading" && (
172
+ <IconButton label={text.pause(name)} disabled={disabled} onClick={() => store.pause(item.id)}>
173
+ <PauseIcon />
174
+ </IconButton>
175
+ )}
176
+ {canUpload && status === "paused" && (
177
+ <IconButton label={text.resume(name)} disabled={disabled} onClick={() => void store.upload([item.id])}>
178
+ <PlayIcon />
179
+ </IconButton>
180
+ )}
181
+ {canUpload && (status === "error" || status === "canceled") && (
182
+ <IconButton label={text.retry(name)} disabled={disabled} onClick={() => void store.upload([item.id])}>
183
+ <RetryIcon />
184
+ </IconButton>
185
+ )}
186
+ {inFlight || status === "paused" ? (
187
+ <IconButton label={text.cancel(name)} disabled={disabled} onClick={() => store.cancel(item.id)}>
188
+ <XIcon />
189
+ </IconButton>
190
+ ) : (
191
+ removable && (
192
+ <IconButton label={text.remove(name)} disabled={disabled} onClick={() => store.remove(item.id)}>
193
+ <XIcon />
194
+ </IconButton>
195
+ )
196
+ )}
197
+ </div>
198
+ </li>
199
+ );
200
+ });
201
+
202
+ interface ExistingFileRowProps extends SharedRowProps {
203
+ file: ExistingFile;
204
+ onRemove?(file: ExistingFile): void;
205
+ }
206
+
207
+ /** A file already stored on the server. */
208
+ export const ExistingFileRow = memo(function ExistingFileRow({
209
+ file,
210
+ onRemove,
211
+ preview,
212
+ removable,
213
+ disabled,
214
+ locale,
215
+ text,
216
+ classNames,
217
+ }: ExistingFileRowProps) {
218
+ const kind = fileKind(file);
219
+ const meta = [file.size !== undefined ? formatBytes(file.size, locale) : null, text.uploaded]
220
+ .filter(Boolean)
221
+ .join(" · ");
222
+
223
+ return (
224
+ <li className={cx("fu-item", classNames?.item)} data-status="success" data-existing="">
225
+ <Thumb kind={kind} className={classNames?.thumb}>
226
+ {preview && kind === "image" && file.url && (
227
+ <img
228
+ src={file.url}
229
+ alt=""
230
+ loading="lazy"
231
+ decoding="async"
232
+ onError={(event) => {
233
+ event.currentTarget.hidden = true;
234
+ }}
235
+ />
236
+ )}
237
+ </Thumb>
238
+
239
+ <div className="fu-body">
240
+ <span className="fu-name" title={file.name}>
241
+ {file.name}
242
+ </span>
243
+ <span className="fu-meta">{meta}</span>
244
+ </div>
245
+
246
+ <div className={cx("fu-actions", classNames?.actions)}>
247
+ {file.url && (
248
+ <a
249
+ className="fu-icon-button"
250
+ href={file.url}
251
+ download={file.name}
252
+ target="_blank"
253
+ rel="noreferrer"
254
+ aria-label={text.download(file.name)}
255
+ title={text.download(file.name)}
256
+ >
257
+ <DownloadIcon />
258
+ </a>
259
+ )}
260
+ {removable && onRemove && (
261
+ <IconButton label={text.remove(file.name)} disabled={disabled} onClick={() => onRemove(file)}>
262
+ <XIcon />
263
+ </IconButton>
264
+ )}
265
+ </div>
266
+ </li>
267
+ );
268
+ });