@ttt-productions/file-input 0.11.9 → 0.11.11

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.
@@ -1,6 +1,4 @@
1
1
  export type InferKind = "image" | "video" | "audio";
2
- /** The neutral pass-through type for unknown picker metadata. */
3
- export declare const NEUTRAL_CONTENT_TYPE = "application/octet-stream";
4
2
  /**
5
3
  * Returns a valid, parameter-less media MIME for the given File.
6
4
  *
@@ -1 +1 @@
1
- {"version":3,"file":"infer-content-type.d.ts","sourceRoot":"","sources":["../../src/lib/infer-content-type.ts"],"names":[],"mappings":"AAmBA,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;AA6CpD,iEAAiE;AACjE,eAAO,MAAM,oBAAoB,6BAA6B,CAAC;AAyB/D;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,YAAY,CAAC,EAAE,SAAS,GAAG,MAAM,CAgB7E;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,IAAI,EAAE,YAAY,CAAC,EAAE,SAAS,GAAG,IAAI,CAIpF"}
1
+ {"version":3,"file":"infer-content-type.d.ts","sourceRoot":"","sources":["../../src/lib/infer-content-type.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;AAoEpD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,EAAE,YAAY,CAAC,EAAE,SAAS,GAAG,MAAM,CAgB7E;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,IAAI,EAAE,YAAY,CAAC,EAAE,SAAS,GAAG,IAAI,CAIpF"}
@@ -16,6 +16,7 @@
16
16
  // behavior defaulted such files to `image/jpeg`, which invented a false media
17
17
  // fact the whole pipeline then trusted. upload-core accepts the neutral type
18
18
  // only via its explicit opt-in (`allowNeutralContentType`).
19
+ import { NEUTRAL_CONTENT_TYPE } from "@ttt-productions/media-schemas";
19
20
  const EXT_TO_MIME = {
20
21
  // image
21
22
  jpg: "image/jpeg",
@@ -55,8 +56,6 @@ const KIND_DEFAULT = {
55
56
  audio: "audio/webm",
56
57
  };
57
58
  const BASE_MEDIA_MIME_RE = /^(image|video|audio)\/[a-z0-9.+-]+$/;
58
- /** The neutral pass-through type for unknown picker metadata. */
59
- export const NEUTRAL_CONTENT_TYPE = "application/octet-stream";
60
59
  /**
61
60
  * Extracts the parameter-less base MIME ("audio/webm" from
62
61
  * "audio/webm;codecs=opus"), lowercased, when it is a usable media type.
@@ -67,7 +66,7 @@ function parseBaseMime(mime) {
67
66
  if (!mime)
68
67
  return null;
69
68
  const base = mime.split(";")[0].trim().toLowerCase();
70
- if (base === "application/octet-stream")
69
+ if (base === NEUTRAL_CONTENT_TYPE)
71
70
  return null;
72
71
  return BASE_MEDIA_MIME_RE.test(base) ? base : null;
73
72
  }
@@ -1 +1 @@
1
- {"version":3,"file":"infer-content-type.js","sourceRoot":"","sources":["../../src/lib/infer-content-type.ts"],"names":[],"mappings":"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","sourcesContent":["// Defense-in-depth: guarantee every File we emit has a NORMALIZED, honest\n// content type. If file.type is missing or unusable, infer from the extension;\n// with a caller-declared kind (recorder/capture — the caller KNOWS what it\n// produced) fall back to that kind's default container.\n//\n// MediaRecorder (and some pickers) report parameterized types such as\n// \"audio/webm;codecs=opus\". Downstream storage rules validate a\n// parameter-less base type, so we always emit the base (\"audio/webm\"),\n// never the parameterized form.\n//\n// UNKNOWN PICKER METADATA IS NOT FABRICATED (canonical-upload-content-\n// classification, 2026-07-31): when there is no declared kind, no usable MIME,\n// and no known extension, the file passes through as the NEUTRAL\n// `application/octet-stream` so the server's canonical byte inspector — the\n// only classification authority — sees \"unknown\" as unknown. The previous\n// behavior defaulted such files to `image/jpeg`, which invented a false media\n// fact the whole pipeline then trusted. upload-core accepts the neutral type\n// only via its explicit opt-in (`allowNeutralContentType`).\n\nexport type InferKind = \"image\" | \"video\" | \"audio\";\n\nconst EXT_TO_MIME: Record<string, string> = {\n // image\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n webp: \"image/webp\",\n avif: \"image/avif\",\n heic: \"image/heic\",\n heif: \"image/heif\",\n // video\n mp4: \"video/mp4\",\n m4v: \"video/mp4\",\n mov: \"video/quicktime\",\n webm: \"video/webm\",\n mkv: \"video/x-matroska\",\n // audio\n mp3: \"audio/mpeg\",\n m4a: \"audio/mp4\",\n aac: \"audio/aac\",\n wav: \"audio/wav\",\n ogg: \"audio/ogg\",\n oga: \"audio/ogg\",\n flac: \"audio/flac\",\n};\n\n// Containers that legitimately hold either audio or video. When the caller\n// declares a kind (e.g. the record dialog's chosen recording kind), that kind\n// picks the matching side; without a kind we keep the historical single-kind\n// guess in EXT_TO_MIME (webm → video, ogg → audio).\nconst AMBIGUOUS_EXT_TO_MIME: Record<string, Partial<Record<InferKind, string>>> = {\n webm: { audio: \"audio/webm\", video: \"video/webm\" },\n ogg: { audio: \"audio/ogg\", video: \"video/ogg\" },\n};\n\nconst KIND_DEFAULT: Record<InferKind, string> = {\n image: \"image/jpeg\",\n video: \"video/webm\",\n audio: \"audio/webm\",\n};\n\nconst BASE_MEDIA_MIME_RE = /^(image|video|audio)\\/[a-z0-9.+-]+$/;\n\n/** The neutral pass-through type for unknown picker metadata. */\nexport const NEUTRAL_CONTENT_TYPE = \"application/octet-stream\";\n\n/**\n * Extracts the parameter-less base MIME (\"audio/webm\" from\n * \"audio/webm;codecs=opus\"), lowercased, when it is a usable media type.\n * Returns null for empty values, application/octet-stream, and anything\n * whose base is not image/*, video/*, or audio/*.\n */\nfunction parseBaseMime(mime: string | undefined | null): string | null {\n if (!mime) return null;\n const base = mime.split(\";\")[0].trim().toLowerCase();\n if (base === \"application/octet-stream\") return null;\n return BASE_MEDIA_MIME_RE.test(base) ? base : null;\n}\n\nfunction kindOfMime(base: string): InferKind {\n return base.slice(0, base.indexOf(\"/\")) as InferKind;\n}\n\nfunction extOf(name: string): string | null {\n const dot = name.lastIndexOf(\".\");\n if (dot < 0 || dot === name.length - 1) return null;\n return name.slice(dot + 1).toLowerCase();\n}\n\n/**\n * Returns a valid, parameter-less media MIME for the given File.\n *\n * Precedence:\n * 1. The base of file.type (parameters stripped, lowercased) when valid —\n * unless `fallbackKind` is supplied and disagrees with the declared\n * top-level kind. A caller-declared kind (e.g. the record dialog's chosen\n * recording kind) is authoritative context, so a conflicting declared type\n * is treated as untrustworthy and resolution falls through to the\n * extension.\n * 2. Extension lookup. For ambiguous containers (webm/ogg exist as both audio\n * and video) a supplied `fallbackKind` picks the matching side; unambiguous\n * extensions keep their single mapping.\n * 3. The kind default for `fallbackKind`; without one, the NEUTRAL type — an\n * unknown picker file is never rewritten into a false media fact.\n */\nexport function inferContentType(file: File, fallbackKind?: InferKind): string {\n const base = parseBaseMime(file.type);\n if (base && (!fallbackKind || kindOfMime(base) === fallbackKind)) return base;\n\n const ext = extOf(file.name);\n if (ext) {\n const byKind = fallbackKind ? AMBIGUOUS_EXT_TO_MIME[ext]?.[fallbackKind] : undefined;\n if (byKind) return byKind;\n if (EXT_TO_MIME[ext]) return EXT_TO_MIME[ext];\n }\n\n if (fallbackKind) return KIND_DEFAULT[fallbackKind];\n\n // No declared kind, no usable MIME, no known extension: NEUTRAL, never a\n // fabricated media type. The server inspector owns classification.\n return NEUTRAL_CONTENT_TYPE;\n}\n\n/**\n * Returns a File whose .type is exactly the valid, parameter-less media MIME\n * from inferContentType. If the original file already carries that exact\n * type, it is returned unchanged; otherwise (missing type, parameterized\n * type, wrong case, or a type overridden by fallbackKind precedence) the\n * bytes are wrapped in a new File with the normalized type.\n */\nexport function ensureFileWithContentType(file: File, fallbackKind?: InferKind): File {\n const type = inferContentType(file, fallbackKind);\n if (file.type === type) return file;\n return new File([file], file.name, { type, lastModified: file.lastModified });\n}\n"]}
1
+ {"version":3,"file":"infer-content-type.js","sourceRoot":"","sources":["../../src/lib/infer-content-type.ts"],"names":[],"mappings":"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","sourcesContent":["// Defense-in-depth: guarantee every File we emit has a NORMALIZED, honest\n// content type. If file.type is missing or unusable, infer from the extension;\n// with a caller-declared kind (recorder/capture — the caller KNOWS what it\n// produced) fall back to that kind's default container.\n//\n// MediaRecorder (and some pickers) report parameterized types such as\n// \"audio/webm;codecs=opus\". Downstream storage rules validate a\n// parameter-less base type, so we always emit the base (\"audio/webm\"),\n// never the parameterized form.\n//\n// UNKNOWN PICKER METADATA IS NOT FABRICATED (canonical-upload-content-\n// classification, 2026-07-31): when there is no declared kind, no usable MIME,\n// and no known extension, the file passes through as the NEUTRAL\n// `application/octet-stream` so the server's canonical byte inspector — the\n// only classification authority — sees \"unknown\" as unknown. The previous\n// behavior defaulted such files to `image/jpeg`, which invented a false media\n// fact the whole pipeline then trusted. upload-core accepts the neutral type\n// only via its explicit opt-in (`allowNeutralContentType`).\n\nimport { NEUTRAL_CONTENT_TYPE } from \"@ttt-productions/media-schemas\";\n\nexport type InferKind = \"image\" | \"video\" | \"audio\";\n\nconst EXT_TO_MIME: Record<string, string> = {\n // image\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n webp: \"image/webp\",\n avif: \"image/avif\",\n heic: \"image/heic\",\n heif: \"image/heif\",\n // video\n mp4: \"video/mp4\",\n m4v: \"video/mp4\",\n mov: \"video/quicktime\",\n webm: \"video/webm\",\n mkv: \"video/x-matroska\",\n // audio\n mp3: \"audio/mpeg\",\n m4a: \"audio/mp4\",\n aac: \"audio/aac\",\n wav: \"audio/wav\",\n ogg: \"audio/ogg\",\n oga: \"audio/ogg\",\n flac: \"audio/flac\",\n};\n\n// Containers that legitimately hold either audio or video. When the caller\n// declares a kind (e.g. the record dialog's chosen recording kind), that kind\n// picks the matching side; without a kind we keep the historical single-kind\n// guess in EXT_TO_MIME (webm → video, ogg → audio).\nconst AMBIGUOUS_EXT_TO_MIME: Record<string, Partial<Record<InferKind, string>>> = {\n webm: { audio: \"audio/webm\", video: \"video/webm\" },\n ogg: { audio: \"audio/ogg\", video: \"video/ogg\" },\n};\n\nconst KIND_DEFAULT: Record<InferKind, string> = {\n image: \"image/jpeg\",\n video: \"video/webm\",\n audio: \"audio/webm\",\n};\n\nconst BASE_MEDIA_MIME_RE = /^(image|video|audio)\\/[a-z0-9.+-]+$/;\n\n/**\n * Extracts the parameter-less base MIME (\"audio/webm\" from\n * \"audio/webm;codecs=opus\"), lowercased, when it is a usable media type.\n * Returns null for empty values, application/octet-stream, and anything\n * whose base is not image/*, video/*, or audio/*.\n */\nfunction parseBaseMime(mime: string | undefined | null): string | null {\n if (!mime) return null;\n const base = mime.split(\";\")[0].trim().toLowerCase();\n if (base === NEUTRAL_CONTENT_TYPE) return null;\n return BASE_MEDIA_MIME_RE.test(base) ? base : null;\n}\n\nfunction kindOfMime(base: string): InferKind {\n return base.slice(0, base.indexOf(\"/\")) as InferKind;\n}\n\nfunction extOf(name: string): string | null {\n const dot = name.lastIndexOf(\".\");\n if (dot < 0 || dot === name.length - 1) return null;\n return name.slice(dot + 1).toLowerCase();\n}\n\n/**\n * Returns a valid, parameter-less media MIME for the given File.\n *\n * Precedence:\n * 1. The base of file.type (parameters stripped, lowercased) when valid —\n * unless `fallbackKind` is supplied and disagrees with the declared\n * top-level kind. A caller-declared kind (e.g. the record dialog's chosen\n * recording kind) is authoritative context, so a conflicting declared type\n * is treated as untrustworthy and resolution falls through to the\n * extension.\n * 2. Extension lookup. For ambiguous containers (webm/ogg exist as both audio\n * and video) a supplied `fallbackKind` picks the matching side; unambiguous\n * extensions keep their single mapping.\n * 3. The kind default for `fallbackKind`; without one, the NEUTRAL type — an\n * unknown picker file is never rewritten into a false media fact.\n */\nexport function inferContentType(file: File, fallbackKind?: InferKind): string {\n const base = parseBaseMime(file.type);\n if (base && (!fallbackKind || kindOfMime(base) === fallbackKind)) return base;\n\n const ext = extOf(file.name);\n if (ext) {\n const byKind = fallbackKind ? AMBIGUOUS_EXT_TO_MIME[ext]?.[fallbackKind] : undefined;\n if (byKind) return byKind;\n if (EXT_TO_MIME[ext]) return EXT_TO_MIME[ext];\n }\n\n if (fallbackKind) return KIND_DEFAULT[fallbackKind];\n\n // No declared kind, no usable MIME, no known extension: NEUTRAL, never a\n // fabricated media type. The server inspector owns classification.\n return NEUTRAL_CONTENT_TYPE;\n}\n\n/**\n * Returns a File whose .type is exactly the valid, parameter-less media MIME\n * from inferContentType. If the original file already carries that exact\n * type, it is returned unchanged; otherwise (missing type, parameterized\n * type, wrong case, or a type overridden by fallbackKind precedence) the\n * bytes are wrapped in a new File with the normalized type.\n */\nexport function ensureFileWithContentType(file: File, fallbackKind?: InferKind): File {\n const type = inferContentType(file, fallbackKind);\n if (file.type === type) return file;\n return new File([file], file.name, { type, lastModified: file.lastModified });\n}\n"]}
@@ -2,9 +2,9 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState, useId } from "react";
4
4
  import { getSimplifiedMediaType } from "@ttt-productions/media-schemas";
5
- import { Alert, AlertDescription, Button, Card, Input, Progress } from "@ttt-productions/ui-core/react";
5
+ import { Alert, AlertDescription, Button, Card, Input, Progress, Spinner } from "@ttt-productions/ui-core/react";
6
6
  import { cn } from "@ttt-productions/ui-core";
7
- import { Info, X, Upload, Film, Music, Paperclip, AlertTriangle, Loader2 } from "lucide-react";
7
+ import { Info, X, Upload, Film, Music, Paperclip, AlertTriangle } from "lucide-react";
8
8
  import { validateMediaDuration } from "../../lib/validate-media-duration.js";
9
9
  import { ImageCropperModal } from "./image-cropper-modal.js";
10
10
  const MIME_MAP = {
@@ -167,7 +167,7 @@ export function FileInput(props) {
167
167
  });
168
168
  };
169
169
  const isUploading = typeof uploadProgress === "number" && isLoading;
170
- return (_jsxs("div", { className: cn("relative w-full", className), children: [localError && (_jsxs(Alert, { variant: "destructive", className: "mb-2", children: [_jsx(AlertTriangle, { className: "icon-xs" }), _jsx(AlertDescription, { children: localError.message })] })), _jsx(Input, { id: inputId, type: "file", accept: acceptString, onChange: handleFileChange, className: "hidden", disabled: disabled || isLoading, ref: fileInputRef }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("label", { htmlFor: inputId, className: "w-full flex-grow", children: _jsx(Button, { asChild: true, disabled: disabled || isLoading, variant: variant, size: size, className: cn("w-full relative overflow-hidden cursor-pointer", { "icon-xl p-0": size === "icon" }), children: _jsxs("span", { className: "relative w-full h-full center-row", children: [isUploading && (_jsx(Progress, { value: uploadProgress ?? 0, className: "absolute left-0 top-0 h-full w-full z-0 bg-accent" })), _jsx("span", { className: "z-10 center-row w-full", children: isLoading ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "mr-2 spinner-xs" }), " ", isUploading ? `${(uploadProgress ?? 0).toFixed(0)}%` : "Processing..."] })) : fileValue ? (_jsxs("span", { className: "flex items-center justify-between w-full", children: [_jsx("span", { className: "truncate pr-2", children: getSelectedFileLabel(fileValue) }), _jsx(Button, { variant: "ghost", size: "icon", className: "icon-sm hover:bg-destructive/20 shrink-0", onClick: handleClear, children: _jsx(X, { className: "icon-xs" }) })] })) : (_jsx(_Fragment, { children: buttonLabel === "Attach"
170
+ return (_jsxs("div", { className: cn("relative w-full", className), children: [localError && (_jsxs(Alert, { variant: "destructive", className: "mb-2", children: [_jsx(AlertTriangle, { className: "icon-xs" }), _jsx(AlertDescription, { children: localError.message })] })), _jsx(Input, { id: inputId, type: "file", accept: acceptString, onChange: handleFileChange, className: "hidden", disabled: disabled || isLoading, ref: fileInputRef }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("label", { htmlFor: inputId, className: "w-full flex-grow", children: _jsx(Button, { asChild: true, disabled: disabled || isLoading, variant: variant, size: size, className: cn("w-full relative overflow-hidden cursor-pointer", { "icon-xl p-0": size === "icon" }), children: _jsxs("span", { className: "relative w-full h-full center-row", children: [isUploading && (_jsx(Progress, { value: uploadProgress ?? 0, className: "absolute left-0 top-0 h-full w-full z-0 bg-accent" })), _jsx("span", { className: "z-10 center-row w-full", children: isLoading ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { size: "xs", className: "mr-2" }), " ", isUploading ? `${(uploadProgress ?? 0).toFixed(0)}%` : "Processing..."] })) : fileValue ? (_jsxs("span", { className: "flex items-center justify-between w-full", children: [_jsx("span", { className: "truncate pr-2", children: getSelectedFileLabel(fileValue) }), _jsx(Button, { variant: "ghost", size: "icon", className: "icon-sm hover:bg-destructive/20 shrink-0", onClick: handleClear, children: _jsx(X, { className: "icon-xs" }) })] })) : (_jsx(_Fragment, { children: buttonLabel === "Attach"
171
171
  ? _jsxs(_Fragment, { children: [_jsx(Paperclip, { className: "mr-2 icon-xs" }), " ", buttonLabel] })
172
172
  : _jsxs(_Fragment, { children: [getUploadIcon(), " ", buttonLabel] }) })) })] }) }) }), detailsDescriptionParts.length > 0 && size !== "icon" && (_jsx(Button, { type: "button", variant: "default", size: "icon", onClick: () => setShowDetails((p) => !p), "aria-label": "Show file processing details", className: "flex-shrink-0 icon-xl", children: _jsx(Info, { className: "icon-sm" }) }))] }), showDetails && detailsDescriptionParts.length > 0 && size !== "icon" && (_jsx(Card, { className: "page-card w-full mt-2", children: _jsx("div", { className: "text-center p-2 space-y-0.5", children: detailsDescriptionParts.map((part, i) => (_jsx("p", { className: "text-small font-bold", children: part }, i))) }) })), cropConfig && imageToCrop && (_jsx(ImageCropperModal, { isOpen: isCropperOpen, onClose: () => {
173
173
  setIsCropperOpen(false);
@@ -1 +1 @@
1
- {"version":3,"file":"file-input.js","sourceRoot":"","sources":["../../../src/react/components/file-input.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, useId } from \"react\";\nimport { getSimplifiedMediaType, type SimplifiedMediaType } from \"@ttt-productions/media-schemas\";\nimport { Alert, AlertDescription, Button, Card, Input, Progress } from \"@ttt-productions/ui-core/react\";\nimport { cn } from \"@ttt-productions/ui-core\";\nimport { Info, X, Upload, Film, Music, Paperclip, AlertTriangle, Loader2 } from \"lucide-react\";\n\nimport type { FileInputError, FileInputProps } from \"../../types.js\";\nimport { validateMediaDuration } from \"../../lib/validate-media-duration.js\";\nimport { ImageCropperModal } from \"./image-cropper-modal.js\";\n\nconst MIME_MAP: Record<SimplifiedMediaType, string> = {\n image: \"image/jpeg, image/png, image/gif, image/webp, image/svg+xml, image/bmp, image/avif\",\n video: \"video/*, .mkv\",\n audio: \"audio/*\",\n other: \".pdf, .doc, .docx\"\n};\n\nfunction err(code: FileInputError[\"code\"], message: string, details?: Record<string, unknown>): FileInputError {\n return { code, message, details };\n}\n\nfunction getSelectedFileLabel(file: File): string {\n switch (getSimplifiedMediaType(file)) {\n case \"image\": return \"Image selected\";\n case \"video\": return \"Video selected\";\n case \"audio\": return \"Audio selected\";\n default: return \"File selected\";\n }\n}\n\nexport function FileInput(props: FileInputProps) {\n const {\n acceptTypes,\n maxSizeMB,\n selectedFile,\n onChange,\n onError,\n isLoading = false,\n disabled = false,\n buttonLabel = \"Upload File\",\n className,\n uploadProgress = null,\n variant = \"default\",\n size = \"default\",\n cropConfig,\n videoMaxDurationSec,\n audioMaxDurationSec,\n defaultShowDetails = false,\n } = props;\n\n const fileInputRef = useRef<HTMLInputElement>(null);\n const lastPreviewUrlRef = useRef<string | null>(null);\n\n const revokeLastPreviewUrl = useCallback(() => {\n if (!lastPreviewUrlRef.current) return;\n try {\n URL.revokeObjectURL(lastPreviewUrlRef.current);\n } catch {}\n lastPreviewUrlRef.current = null;\n }, []);\n\n const makePreviewUrl = useCallback((blob: Blob) => {\n revokeLastPreviewUrl();\n const url = URL.createObjectURL(blob);\n lastPreviewUrlRef.current = url;\n return url;\n }, [revokeLastPreviewUrl]);\n\n useEffect(() => {\n return () => revokeLastPreviewUrl();\n }, [revokeLastPreviewUrl]);\n const uniqueId = useId();\n const inputId = `file-input-${uniqueId}`;\n\n const [internalSelected, setInternalSelected] = useState<File | null>(null);\n const fileValue = selectedFile !== undefined ? selectedFile : internalSelected;\n\n const [localError, setLocalError] = useState<FileInputError | null>(null);\n\n const [isCropperOpen, setIsCropperOpen] = useState(false);\n const [imageToCrop, setImageToCrop] = useState<string | null>(null);\n\n const [showDetails, setShowDetails] = useState(defaultShowDetails);\n\n const acceptString = useMemo(\n () => (acceptTypes.length ? acceptTypes.map((t) => MIME_MAP[t]).join(\", \") : undefined),\n [acceptTypes]\n );\n\n const detailsDescriptionParts = useMemo(() => {\n const parts: string[] = [];\n\n if (cropConfig) {\n const ratioDisplay = cropConfig.aspectRatioDisplay || `${cropConfig.aspectRatio.toFixed(2)}:1`;\n parts.push(`Image will be cropped to a ${ratioDisplay} rect (${cropConfig.outputWidth}x${cropConfig.outputHeight}px).`);\n }\n\n const sizeParts = acceptTypes\n .map((t) => (maxSizeMB[t] ? `${t.charAt(0).toUpperCase() + t.slice(1)} (${maxSizeMB[t]}MB)` : null))\n .filter(Boolean) as string[];\n\n if (sizeParts.length) parts.push(`Max sizes: ${sizeParts.join(\", \")}.`);\n\n if (videoMaxDurationSec) parts.push(`Max video duration: ${videoMaxDurationSec}s.`);\n if (audioMaxDurationSec) parts.push(`Max audio duration: ${audioMaxDurationSec}s.`);\n\n return parts;\n }, [acceptTypes, maxSizeMB, cropConfig, videoMaxDurationSec, audioMaxDurationSec]);\n\n const getUploadIcon = () => {\n if (acceptTypes.includes(\"image\")) return <Upload className=\"mr-2 icon-xs\" />;\n if (acceptTypes.includes(\"video\")) return <Film className=\"mr-2 icon-xs\" />;\n if (acceptTypes.includes(\"audio\")) return <Music className=\"mr-2 icon-xs\" />;\n return <Upload className=\"mr-2 icon-xs\" />;\n };\n\n const clearNative = () => {\n if (fileInputRef.current) fileInputRef.current.value = \"\";\n };\n\n const emitError = useCallback((e: FileInputError, type: SimplifiedMediaType = \"other\") => {\n setLocalError(e);\n onError?.(e);\n onChange({ type, error: e });\n }, [onError, onChange]);\n\n const handleClear = (e: React.MouseEvent<HTMLButtonElement>) => {\n e.stopPropagation();\n e.preventDefault();\n setLocalError(null);\n setInternalSelected(null);\n clearNative();\n onChange({ type: \"other\" });\n };\n\n const handleFileChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {\n const file = event.target.files?.[0];\n clearNative();\n setLocalError(null);\n\n if (!file) {\n setInternalSelected(null);\n onChange({ type: \"other\" });\n return;\n }\n\n const fileType = getSimplifiedMediaType(file);\n if (acceptTypes.length && !acceptTypes.includes(fileType)) {\n emitError(err(\"invalid_type\", `Invalid file type. Allowed: ${acceptTypes.join(\", \")}.`), fileType);\n return;\n }\n\n const maxBytes = (maxSizeMB[fileType] ?? Infinity) * 1024 * 1024;\n if (file.size > maxBytes) {\n emitError(err(\"too_large\", `File too large. Max ${maxSizeMB[fileType]}MB for ${fileType}.`, { size: file.size, maxBytes }), fileType);\n return;\n }\n\n if (fileType === \"video\" && videoMaxDurationSec) {\n const ok = await validateMediaDuration(file, videoMaxDurationSec);\n if (!ok) {\n emitError(err(\"too_long\", `Video too long. Max ${videoMaxDurationSec} seconds.`), fileType);\n return;\n }\n }\n\n if (fileType === \"audio\" && audioMaxDurationSec) {\n const ok = await validateMediaDuration(file, audioMaxDurationSec);\n if (!ok) {\n emitError(err(\"too_long\", `Audio too long. Max ${audioMaxDurationSec} seconds.`), fileType);\n return;\n }\n }\n\n // cropping path\n if (cropConfig && fileType === \"image\") {\n const reader = new FileReader();\n reader.onload = () => {\n setImageToCrop(reader.result as string);\n setIsCropperOpen(true);\n };\n reader.onerror = () => {\n emitError(err(\"read_failed\", \"Failed to read image for cropping.\"));\n };\n reader.readAsDataURL(file);\n setInternalSelected(file);\n return;\n }\n\n // normal selection\n setInternalSelected(file);\n const previewUrl = makePreviewUrl(file);\n onChange({ type: fileType, file, previewUrl, revokePreviewUrl: revokeLastPreviewUrl });\n }, [acceptTypes, maxSizeMB, cropConfig, videoMaxDurationSec, audioMaxDurationSec, emitError, makePreviewUrl, onChange, revokeLastPreviewUrl]);\n\n const handleCropComplete = (blob: Blob | null) => {\n setIsCropperOpen(false);\n\n const original = internalSelected;\n setImageToCrop(null);\n\n if (!blob) {\n emitError(err(\"crop_failed\", \"Cropping failed.\"), \"image\");\n return;\n }\n\n onChange({\n type: \"image\",\n file: original ?? undefined,\n blob,\n previewUrl: makePreviewUrl(blob),\n revokePreviewUrl: revokeLastPreviewUrl,\n });\n };\n\n const isUploading = typeof uploadProgress === \"number\" && isLoading;\n\n return (\n <div className={cn(\"relative w-full\", className)}>\n {localError && (\n <Alert variant=\"destructive\" className=\"mb-2\">\n <AlertTriangle className=\"icon-xs\" />\n <AlertDescription>{localError.message}</AlertDescription>\n </Alert>\n )}\n\n <Input\n id={inputId}\n type=\"file\"\n accept={acceptString}\n onChange={handleFileChange}\n className=\"hidden\"\n disabled={disabled || isLoading}\n ref={fileInputRef}\n />\n\n <div className=\"flex items-center gap-2\">\n <label htmlFor={inputId} className=\"w-full flex-grow\">\n <Button\n asChild\n disabled={disabled || isLoading}\n variant={variant}\n size={size}\n className={cn(\"w-full relative overflow-hidden cursor-pointer\", { \"icon-xl p-0\": size === \"icon\" })}\n >\n <span className=\"relative w-full h-full center-row\">\n {isUploading && (\n <Progress value={uploadProgress ?? 0} className=\"absolute left-0 top-0 h-full w-full z-0 bg-accent\" />\n )}\n\n <span className=\"z-10 center-row w-full\">\n {isLoading ? (\n <><Loader2 className=\"mr-2 spinner-xs\" /> {isUploading ? `${(uploadProgress ?? 0).toFixed(0)}%` : \"Processing...\"}</>\n ) : fileValue ? (\n <span className=\"flex items-center justify-between w-full\">\n <span className=\"truncate pr-2\">{getSelectedFileLabel(fileValue)}</span>\n <Button variant=\"ghost\" size=\"icon\" className=\"icon-sm hover:bg-destructive/20 shrink-0\" onClick={handleClear}>\n <X className=\"icon-xs\" />\n </Button>\n </span>\n ) : (\n <>\n {buttonLabel === \"Attach\"\n ? <><Paperclip className=\"mr-2 icon-xs\" /> {buttonLabel}</>\n : <>{getUploadIcon()} {buttonLabel}</>}\n </>\n )}\n </span>\n </span>\n </Button>\n </label>\n\n {detailsDescriptionParts.length > 0 && size !== \"icon\" && (\n <Button\n type=\"button\"\n variant=\"default\"\n size=\"icon\"\n onClick={() => setShowDetails((p) => !p)}\n aria-label=\"Show file processing details\"\n className=\"flex-shrink-0 icon-xl\"\n >\n <Info className=\"icon-sm\" />\n </Button>\n )}\n </div>\n\n {showDetails && detailsDescriptionParts.length > 0 && size !== \"icon\" && (\n <Card className=\"page-card w-full mt-2\">\n <div className=\"text-center p-2 space-y-0.5\">\n {detailsDescriptionParts.map((part, i) => (\n <p key={i} className=\"text-small font-bold\">{part}</p>\n ))}\n </div>\n </Card>\n )}\n\n {cropConfig && imageToCrop && (\n <ImageCropperModal\n isOpen={isCropperOpen}\n onClose={() => {\n setIsCropperOpen(false);\n setImageToCrop(null);\n }}\n imageSrc={imageToCrop}\n aspectRatio={cropConfig.aspectRatio}\n shape={cropConfig.shape}\n outputWidth={cropConfig.outputWidth}\n outputHeight={cropConfig.outputHeight}\n onCropComplete={handleCropComplete}\n />\n )}\n </div>\n );\n}\n"]}
1
+ {"version":3,"file":"file-input.js","sourceRoot":"","sources":["../../../src/react/components/file-input.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, useId } from \"react\";\nimport { getSimplifiedMediaType, type SimplifiedMediaType } from \"@ttt-productions/media-schemas\";\nimport { Alert, AlertDescription, Button, Card, Input, Progress, Spinner } from \"@ttt-productions/ui-core/react\";\nimport { cn } from \"@ttt-productions/ui-core\";\nimport { Info, X, Upload, Film, Music, Paperclip, AlertTriangle } from \"lucide-react\";\n\nimport type { FileInputError, FileInputProps } from \"../../types.js\";\nimport { validateMediaDuration } from \"../../lib/validate-media-duration.js\";\nimport { ImageCropperModal } from \"./image-cropper-modal.js\";\n\nconst MIME_MAP: Record<SimplifiedMediaType, string> = {\n image: \"image/jpeg, image/png, image/gif, image/webp, image/svg+xml, image/bmp, image/avif\",\n video: \"video/*, .mkv\",\n audio: \"audio/*\",\n other: \".pdf, .doc, .docx\"\n};\n\nfunction err(code: FileInputError[\"code\"], message: string, details?: Record<string, unknown>): FileInputError {\n return { code, message, details };\n}\n\nfunction getSelectedFileLabel(file: File): string {\n switch (getSimplifiedMediaType(file)) {\n case \"image\": return \"Image selected\";\n case \"video\": return \"Video selected\";\n case \"audio\": return \"Audio selected\";\n default: return \"File selected\";\n }\n}\n\nexport function FileInput(props: FileInputProps) {\n const {\n acceptTypes,\n maxSizeMB,\n selectedFile,\n onChange,\n onError,\n isLoading = false,\n disabled = false,\n buttonLabel = \"Upload File\",\n className,\n uploadProgress = null,\n variant = \"default\",\n size = \"default\",\n cropConfig,\n videoMaxDurationSec,\n audioMaxDurationSec,\n defaultShowDetails = false,\n } = props;\n\n const fileInputRef = useRef<HTMLInputElement>(null);\n const lastPreviewUrlRef = useRef<string | null>(null);\n\n const revokeLastPreviewUrl = useCallback(() => {\n if (!lastPreviewUrlRef.current) return;\n try {\n URL.revokeObjectURL(lastPreviewUrlRef.current);\n } catch {}\n lastPreviewUrlRef.current = null;\n }, []);\n\n const makePreviewUrl = useCallback((blob: Blob) => {\n revokeLastPreviewUrl();\n const url = URL.createObjectURL(blob);\n lastPreviewUrlRef.current = url;\n return url;\n }, [revokeLastPreviewUrl]);\n\n useEffect(() => {\n return () => revokeLastPreviewUrl();\n }, [revokeLastPreviewUrl]);\n const uniqueId = useId();\n const inputId = `file-input-${uniqueId}`;\n\n const [internalSelected, setInternalSelected] = useState<File | null>(null);\n const fileValue = selectedFile !== undefined ? selectedFile : internalSelected;\n\n const [localError, setLocalError] = useState<FileInputError | null>(null);\n\n const [isCropperOpen, setIsCropperOpen] = useState(false);\n const [imageToCrop, setImageToCrop] = useState<string | null>(null);\n\n const [showDetails, setShowDetails] = useState(defaultShowDetails);\n\n const acceptString = useMemo(\n () => (acceptTypes.length ? acceptTypes.map((t) => MIME_MAP[t]).join(\", \") : undefined),\n [acceptTypes]\n );\n\n const detailsDescriptionParts = useMemo(() => {\n const parts: string[] = [];\n\n if (cropConfig) {\n const ratioDisplay = cropConfig.aspectRatioDisplay || `${cropConfig.aspectRatio.toFixed(2)}:1`;\n parts.push(`Image will be cropped to a ${ratioDisplay} rect (${cropConfig.outputWidth}x${cropConfig.outputHeight}px).`);\n }\n\n const sizeParts = acceptTypes\n .map((t) => (maxSizeMB[t] ? `${t.charAt(0).toUpperCase() + t.slice(1)} (${maxSizeMB[t]}MB)` : null))\n .filter(Boolean) as string[];\n\n if (sizeParts.length) parts.push(`Max sizes: ${sizeParts.join(\", \")}.`);\n\n if (videoMaxDurationSec) parts.push(`Max video duration: ${videoMaxDurationSec}s.`);\n if (audioMaxDurationSec) parts.push(`Max audio duration: ${audioMaxDurationSec}s.`);\n\n return parts;\n }, [acceptTypes, maxSizeMB, cropConfig, videoMaxDurationSec, audioMaxDurationSec]);\n\n const getUploadIcon = () => {\n if (acceptTypes.includes(\"image\")) return <Upload className=\"mr-2 icon-xs\" />;\n if (acceptTypes.includes(\"video\")) return <Film className=\"mr-2 icon-xs\" />;\n if (acceptTypes.includes(\"audio\")) return <Music className=\"mr-2 icon-xs\" />;\n return <Upload className=\"mr-2 icon-xs\" />;\n };\n\n const clearNative = () => {\n if (fileInputRef.current) fileInputRef.current.value = \"\";\n };\n\n const emitError = useCallback((e: FileInputError, type: SimplifiedMediaType = \"other\") => {\n setLocalError(e);\n onError?.(e);\n onChange({ type, error: e });\n }, [onError, onChange]);\n\n const handleClear = (e: React.MouseEvent<HTMLButtonElement>) => {\n e.stopPropagation();\n e.preventDefault();\n setLocalError(null);\n setInternalSelected(null);\n clearNative();\n onChange({ type: \"other\" });\n };\n\n const handleFileChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {\n const file = event.target.files?.[0];\n clearNative();\n setLocalError(null);\n\n if (!file) {\n setInternalSelected(null);\n onChange({ type: \"other\" });\n return;\n }\n\n const fileType = getSimplifiedMediaType(file);\n if (acceptTypes.length && !acceptTypes.includes(fileType)) {\n emitError(err(\"invalid_type\", `Invalid file type. Allowed: ${acceptTypes.join(\", \")}.`), fileType);\n return;\n }\n\n const maxBytes = (maxSizeMB[fileType] ?? Infinity) * 1024 * 1024;\n if (file.size > maxBytes) {\n emitError(err(\"too_large\", `File too large. Max ${maxSizeMB[fileType]}MB for ${fileType}.`, { size: file.size, maxBytes }), fileType);\n return;\n }\n\n if (fileType === \"video\" && videoMaxDurationSec) {\n const ok = await validateMediaDuration(file, videoMaxDurationSec);\n if (!ok) {\n emitError(err(\"too_long\", `Video too long. Max ${videoMaxDurationSec} seconds.`), fileType);\n return;\n }\n }\n\n if (fileType === \"audio\" && audioMaxDurationSec) {\n const ok = await validateMediaDuration(file, audioMaxDurationSec);\n if (!ok) {\n emitError(err(\"too_long\", `Audio too long. Max ${audioMaxDurationSec} seconds.`), fileType);\n return;\n }\n }\n\n // cropping path\n if (cropConfig && fileType === \"image\") {\n const reader = new FileReader();\n reader.onload = () => {\n setImageToCrop(reader.result as string);\n setIsCropperOpen(true);\n };\n reader.onerror = () => {\n emitError(err(\"read_failed\", \"Failed to read image for cropping.\"));\n };\n reader.readAsDataURL(file);\n setInternalSelected(file);\n return;\n }\n\n // normal selection\n setInternalSelected(file);\n const previewUrl = makePreviewUrl(file);\n onChange({ type: fileType, file, previewUrl, revokePreviewUrl: revokeLastPreviewUrl });\n }, [acceptTypes, maxSizeMB, cropConfig, videoMaxDurationSec, audioMaxDurationSec, emitError, makePreviewUrl, onChange, revokeLastPreviewUrl]);\n\n const handleCropComplete = (blob: Blob | null) => {\n setIsCropperOpen(false);\n\n const original = internalSelected;\n setImageToCrop(null);\n\n if (!blob) {\n emitError(err(\"crop_failed\", \"Cropping failed.\"), \"image\");\n return;\n }\n\n onChange({\n type: \"image\",\n file: original ?? undefined,\n blob,\n previewUrl: makePreviewUrl(blob),\n revokePreviewUrl: revokeLastPreviewUrl,\n });\n };\n\n const isUploading = typeof uploadProgress === \"number\" && isLoading;\n\n return (\n <div className={cn(\"relative w-full\", className)}>\n {localError && (\n <Alert variant=\"destructive\" className=\"mb-2\">\n <AlertTriangle className=\"icon-xs\" />\n <AlertDescription>{localError.message}</AlertDescription>\n </Alert>\n )}\n\n <Input\n id={inputId}\n type=\"file\"\n accept={acceptString}\n onChange={handleFileChange}\n className=\"hidden\"\n disabled={disabled || isLoading}\n ref={fileInputRef}\n />\n\n <div className=\"flex items-center gap-2\">\n <label htmlFor={inputId} className=\"w-full flex-grow\">\n <Button\n asChild\n disabled={disabled || isLoading}\n variant={variant}\n size={size}\n className={cn(\"w-full relative overflow-hidden cursor-pointer\", { \"icon-xl p-0\": size === \"icon\" })}\n >\n <span className=\"relative w-full h-full center-row\">\n {isUploading && (\n <Progress value={uploadProgress ?? 0} className=\"absolute left-0 top-0 h-full w-full z-0 bg-accent\" />\n )}\n\n <span className=\"z-10 center-row w-full\">\n {isLoading ? (\n <><Spinner size=\"xs\" className=\"mr-2\" /> {isUploading ? `${(uploadProgress ?? 0).toFixed(0)}%` : \"Processing...\"}</>\n ) : fileValue ? (\n <span className=\"flex items-center justify-between w-full\">\n <span className=\"truncate pr-2\">{getSelectedFileLabel(fileValue)}</span>\n <Button variant=\"ghost\" size=\"icon\" className=\"icon-sm hover:bg-destructive/20 shrink-0\" onClick={handleClear}>\n <X className=\"icon-xs\" />\n </Button>\n </span>\n ) : (\n <>\n {buttonLabel === \"Attach\"\n ? <><Paperclip className=\"mr-2 icon-xs\" /> {buttonLabel}</>\n : <>{getUploadIcon()} {buttonLabel}</>}\n </>\n )}\n </span>\n </span>\n </Button>\n </label>\n\n {detailsDescriptionParts.length > 0 && size !== \"icon\" && (\n <Button\n type=\"button\"\n variant=\"default\"\n size=\"icon\"\n onClick={() => setShowDetails((p) => !p)}\n aria-label=\"Show file processing details\"\n className=\"flex-shrink-0 icon-xl\"\n >\n <Info className=\"icon-sm\" />\n </Button>\n )}\n </div>\n\n {showDetails && detailsDescriptionParts.length > 0 && size !== \"icon\" && (\n <Card className=\"page-card w-full mt-2\">\n <div className=\"text-center p-2 space-y-0.5\">\n {detailsDescriptionParts.map((part, i) => (\n <p key={i} className=\"text-small font-bold\">{part}</p>\n ))}\n </div>\n </Card>\n )}\n\n {cropConfig && imageToCrop && (\n <ImageCropperModal\n isOpen={isCropperOpen}\n onClose={() => {\n setIsCropperOpen(false);\n setImageToCrop(null);\n }}\n imageSrc={imageToCrop}\n aspectRatio={cropConfig.aspectRatio}\n shape={cropConfig.shape}\n outputWidth={cropConfig.outputWidth}\n outputHeight={cropConfig.outputHeight}\n onCropComplete={handleCropComplete}\n />\n )}\n </div>\n );\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"image-cropper-modal.d.ts","sourceRoot":"","sources":["../../../src/react/components/image-cropper-modal.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAgC,MAAM,OAAO,CAAC;AAOrD,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,CAAC,WAAW,EAAE,IAAI,GAAG,IAAI,KAAK,IAAI,CAAC;CACpD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,sBAAsB,4BA0D9D"}
1
+ {"version":3,"file":"image-cropper-modal.d.ts","sourceRoot":"","sources":["../../../src/react/components/image-cropper-modal.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAgC,MAAM,OAAO,CAAC;AAOrD,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,CAAC,WAAW,EAAE,IAAI,GAAG,IAAI,KAAK,IAAI,CAAC;CACpD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,sBAAsB,4BA2D9D"}
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useCallback } from "react";
4
4
  import Cropper from "react-easy-crop";
5
5
  const CropperComponent = Cropper;
6
- import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Slider } from "@ttt-productions/ui-core/react";
6
+ import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Slider, useAsyncAction } from "@ttt-productions/ui-core/react";
7
7
  import { getCroppedImg } from "../../lib/image-utils.js";
8
8
  export function ImageCropperModal(props) {
9
9
  const { isOpen, onClose, imageSrc, aspectRatio, shape, outputWidth, outputHeight, onCropComplete } = props;
@@ -13,20 +13,17 @@ export function ImageCropperModal(props) {
13
13
  const handleCropComplete = useCallback((_a, pixels) => {
14
14
  setCroppedAreaPixels(pixels);
15
15
  }, []);
16
- const handleConfirm = useCallback(async () => {
16
+ // Confirm renders the crop, then emits it. It is pending for that whole window and
17
+ // ignores a repeat click, so a double click can never emit (and upload) twice.
18
+ const confirm = useAsyncAction(async () => {
17
19
  if (!imageSrc || !croppedAreaPixels)
18
20
  return;
19
- try {
20
- const blob = await getCroppedImg(imageSrc, croppedAreaPixels, 0, { horizontal: false, vertical: false }, outputWidth, outputHeight);
21
- onCropComplete(blob);
22
- onClose();
23
- }
24
- catch {
25
- onCropComplete(null);
26
- }
27
- }, [imageSrc, croppedAreaPixels, onCropComplete, onClose, outputWidth, outputHeight]);
21
+ const blob = await getCroppedImg(imageSrc, croppedAreaPixels, 0, { horizontal: false, vertical: false }, outputWidth, outputHeight);
22
+ onCropComplete(blob);
23
+ onClose();
24
+ }, { onError: () => onCropComplete(null) });
28
25
  if (!imageSrc)
29
26
  return null;
30
- return (_jsx(Dialog, { open: isOpen, onOpenChange: (open) => !open && onClose(), children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Crop Image" }), _jsx(DialogDescription, { children: "Adjust the crop area and zoom level to select the desired portion of your image." })] }), _jsx("div", { className: "relative h-64 w-full bg-muted", children: _jsx(CropperComponent, { image: imageSrc, crop: crop, zoom: zoom, aspect: aspectRatio, cropShape: shape, showGrid: false, onCropChange: setCrop, onZoomChange: setZoom, onCropComplete: handleCropComplete }) }), _jsxs("div", { className: "stack-2", children: [_jsx("label", { htmlFor: "zoom", className: "text-small", children: "Zoom" }), _jsx(Slider, { id: "zoom", min: 1, max: 3, step: 0.1, value: [zoom], onValueChange: (v) => setZoom(v[0]) })] }), _jsxs(DialogFooter, { className: "flex-row justify-end gap-2", children: [_jsx(Button, { variant: "destructive", onClick: onClose, children: "Cancel" }), _jsx(Button, { variant: "default", onClick: handleConfirm, children: "Confirm" })] })] }) }));
27
+ return (_jsx(Dialog, { open: isOpen, onOpenChange: (open) => !open && !confirm.pending && onClose(), children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Crop Image" }), _jsx(DialogDescription, { children: "Adjust the crop area and zoom level to select the desired portion of your image." })] }), _jsx("div", { className: "relative h-64 w-full bg-muted", children: _jsx(CropperComponent, { image: imageSrc, crop: crop, zoom: zoom, aspect: aspectRatio, cropShape: shape, showGrid: false, onCropChange: setCrop, onZoomChange: setZoom, onCropComplete: handleCropComplete }) }), _jsxs("div", { className: "stack-2", children: [_jsx("label", { htmlFor: "zoom", className: "text-small", children: "Zoom" }), _jsx(Slider, { id: "zoom", min: 1, max: 3, step: 0.1, value: [zoom], onValueChange: (v) => setZoom(v[0]) })] }), _jsxs(DialogFooter, { className: "flex-row justify-end gap-2", children: [_jsx(Button, { variant: "destructive", onClick: onClose, disabled: confirm.pending, children: "Cancel" }), _jsx(Button, { variant: "default", onClick: () => void confirm.run(), pending: confirm.pending, children: "Confirm" })] })] }) }));
31
28
  }
32
29
  //# sourceMappingURL=image-cropper-modal.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"image-cropper-modal.js","sourceRoot":"","sources":["../../../src/react/components/image-cropper-modal.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport React, { useState, useCallback } from \"react\";\nimport Cropper, { type Area } from \"react-easy-crop\";\n \nconst CropperComponent = Cropper as unknown as React.ComponentType<any>;\nimport { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Slider } from \"@ttt-productions/ui-core/react\";\nimport { getCroppedImg } from \"../../lib/image-utils.js\";\n\nexport interface ImageCropperModalProps {\n isOpen: boolean;\n onClose: () => void;\n imageSrc: string | null;\n aspectRatio: number;\n shape: \"rect\" | \"round\";\n outputWidth: number;\n outputHeight: number;\n onCropComplete: (croppedBlob: Blob | null) => void;\n}\n\nexport function ImageCropperModal(props: ImageCropperModalProps) {\n const { isOpen, onClose, imageSrc, aspectRatio, shape, outputWidth, outputHeight, onCropComplete } = props;\n\n const [crop, setCrop] = useState({ x: 0, y: 0 });\n const [zoom, setZoom] = useState(1);\n const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);\n\n const handleCropComplete = useCallback((_a: Area, pixels: Area) => {\n setCroppedAreaPixels(pixels);\n }, []);\n\n const handleConfirm = useCallback(async () => {\n if (!imageSrc || !croppedAreaPixels) return;\n try {\n const blob = await getCroppedImg(imageSrc, croppedAreaPixels, 0, { horizontal: false, vertical: false }, outputWidth, outputHeight);\n onCropComplete(blob);\n onClose();\n } catch {\n onCropComplete(null);\n }\n }, [imageSrc, croppedAreaPixels, onCropComplete, onClose, outputWidth, outputHeight]);\n\n if (!imageSrc) return null;\n\n return (\n <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>Crop Image</DialogTitle>\n <DialogDescription>Adjust the crop area and zoom level to select the desired portion of your image.</DialogDescription>\n </DialogHeader>\n\n <div className=\"relative h-64 w-full bg-muted\">\n <CropperComponent\n image={imageSrc}\n crop={crop}\n zoom={zoom}\n aspect={aspectRatio}\n cropShape={shape}\n showGrid={false}\n onCropChange={setCrop}\n onZoomChange={setZoom}\n onCropComplete={handleCropComplete}\n />\n </div>\n\n <div className=\"stack-2\">\n <label htmlFor=\"zoom\" className=\"text-small\">Zoom</label>\n <Slider id=\"zoom\" min={1} max={3} step={0.1} value={[zoom]} onValueChange={(v) => setZoom(v[0])} />\n </div>\n\n <DialogFooter className=\"flex-row justify-end gap-2\">\n <Button variant=\"destructive\" onClick={onClose}>Cancel</Button>\n <Button variant=\"default\" onClick={handleConfirm}>Confirm</Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n );\n}\n"]}
1
+ {"version":3,"file":"image-cropper-modal.js","sourceRoot":"","sources":["../../../src/react/components/image-cropper-modal.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport React, { useState, useCallback } from \"react\";\nimport Cropper, { type Area } from \"react-easy-crop\";\n \nconst CropperComponent = Cropper as unknown as React.ComponentType<any>;\nimport { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Slider, useAsyncAction } from \"@ttt-productions/ui-core/react\";\nimport { getCroppedImg } from \"../../lib/image-utils.js\";\n\nexport interface ImageCropperModalProps {\n isOpen: boolean;\n onClose: () => void;\n imageSrc: string | null;\n aspectRatio: number;\n shape: \"rect\" | \"round\";\n outputWidth: number;\n outputHeight: number;\n onCropComplete: (croppedBlob: Blob | null) => void;\n}\n\nexport function ImageCropperModal(props: ImageCropperModalProps) {\n const { isOpen, onClose, imageSrc, aspectRatio, shape, outputWidth, outputHeight, onCropComplete } = props;\n\n const [crop, setCrop] = useState({ x: 0, y: 0 });\n const [zoom, setZoom] = useState(1);\n const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);\n\n const handleCropComplete = useCallback((_a: Area, pixels: Area) => {\n setCroppedAreaPixels(pixels);\n }, []);\n\n // Confirm renders the crop, then emits it. It is pending for that whole window and\n // ignores a repeat click, so a double click can never emit (and upload) twice.\n const confirm = useAsyncAction(\n async () => {\n if (!imageSrc || !croppedAreaPixels) return;\n const blob = await getCroppedImg(imageSrc, croppedAreaPixels, 0, { horizontal: false, vertical: false }, outputWidth, outputHeight);\n onCropComplete(blob);\n onClose();\n },\n { onError: () => onCropComplete(null) },\n );\n\n if (!imageSrc) return null;\n\n return (\n <Dialog open={isOpen} onOpenChange={(open) => !open && !confirm.pending && onClose()}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>Crop Image</DialogTitle>\n <DialogDescription>Adjust the crop area and zoom level to select the desired portion of your image.</DialogDescription>\n </DialogHeader>\n\n <div className=\"relative h-64 w-full bg-muted\">\n <CropperComponent\n image={imageSrc}\n crop={crop}\n zoom={zoom}\n aspect={aspectRatio}\n cropShape={shape}\n showGrid={false}\n onCropChange={setCrop}\n onZoomChange={setZoom}\n onCropComplete={handleCropComplete}\n />\n </div>\n\n <div className=\"stack-2\">\n <label htmlFor=\"zoom\" className=\"text-small\">Zoom</label>\n <Slider id=\"zoom\" min={1} max={3} step={0.1} value={[zoom]} onValueChange={(v) => setZoom(v[0])} />\n </div>\n\n <DialogFooter className=\"flex-row justify-end gap-2\">\n <Button variant=\"destructive\" onClick={onClose} disabled={confirm.pending}>Cancel</Button>\n <Button variant=\"default\" onClick={() => void confirm.run()} pending={confirm.pending}>Confirm</Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n );\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"media-input.d.ts","sourceRoot":"","sources":["../../../src/react/components/media-input.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAoG,MAAM,OAAO,CAAC;AAoBzH,OAAO,KAAK,EAAkB,gBAAgB,EAAE,eAAe,EAAqB,MAAM,gBAAgB,CAAC;AAoH3G,eAAO,MAAM,UAAU,0FAsnBrB,CAAC"}
1
+ {"version":3,"file":"media-input.d.ts","sourceRoot":"","sources":["../../../src/react/components/media-input.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAoG,MAAM,OAAO,CAAC;AAqBzH,OAAO,KAAK,EAAkB,gBAAgB,EAAE,eAAe,EAAqB,MAAM,gBAAgB,CAAC;AAoH3G,eAAO,MAAM,UAAU,0FA0oBrB,CAAC"}
@@ -2,14 +2,14 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
4
4
  import { getSimplifiedMediaType } from "@ttt-productions/media-schemas";
5
- import { Info, Camera, Mic, Video, Upload, X, Loader2, Plus } from "lucide-react";
6
- import { Alert, AlertDescription, Button, Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Progress, } from "@ttt-productions/ui-core/react";
5
+ import { Info, Camera, Mic, Video, Upload, X, Plus } from "lucide-react";
6
+ import { Alert, AlertDescription, Button, Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Progress, Spinner, } from "@ttt-productions/ui-core/react";
7
7
  import { cn } from "@ttt-productions/ui-core";
8
8
  import { MediaPreview } from "@ttt-productions/media-viewer/react";
9
9
  import { DEFAULT_PROGRESS_BAR_MIN_BYTES } from "../../index.js";
10
10
  import { RecordDialog } from "./record-dialog.js";
11
- import { ensureFileWithContentType, NEUTRAL_CONTENT_TYPE } from "../../lib/infer-content-type.js";
12
- import { projectAcceptTokens } from "@ttt-productions/media-schemas";
11
+ import { ensureFileWithContentType } from "../../lib/infer-content-type.js";
12
+ import { NEUTRAL_CONTENT_TYPE, projectAcceptTokens } from "@ttt-productions/media-schemas";
13
13
  import { ImageCropperModal } from "./image-cropper-modal.js";
14
14
  import { MediaConstraintsHint } from "./media-constraints-hint.js";
15
15
  import { PhotoCaptureModal } from "./photo-capture-modal.js";
@@ -139,6 +139,8 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
139
139
  // via the trigger button; the imperative `openSelection()` handle opens it when
140
140
  // more than one action is enabled.
141
141
  const [menuOpen, setMenuOpen] = useState(false);
142
+ const [validating, setValidating] = useState(false);
143
+ const [emittedSize, setEmittedSize] = useState(null);
142
144
  // Prevent memory leaks from object URLs
143
145
  const lastObjectUrlRef = useRef(null);
144
146
  const revokeLastObjectUrl = useCallback(() => {
@@ -200,13 +202,15 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
200
202
  const rawKind = payload.meta?.kind;
201
203
  const mediaKind = rawKind === "image" || rawKind === "video" || rawKind === "audio" ? rawKind : "image";
202
204
  const resolvedSpec = resolveActiveSpec(spec, mediaKind);
205
+ if (payload.file)
206
+ setEmittedSize(payload.file.size);
203
207
  onChange({ spec: resolvedSpec, ...payload });
204
208
  }, [onChange, spec]);
205
209
  const fail = useCallback((e) => {
206
210
  setLocalError(e);
207
211
  emit({ error: e });
208
212
  }, [emit]);
209
- const handleSelected = useCallback(async (file, previewUrl, action = undefined) => {
213
+ const validateAndEmit = useCallback(async (file, previewUrl, action = undefined) => {
210
214
  setLocalError(null);
211
215
  // Defense-in-depth: normalize the content type before any downstream
212
216
  // handling. Recorder/capture files carry their declared kind; a picker
@@ -311,6 +315,18 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
311
315
  }
312
316
  emit({ file: safeFile, previewUrl, meta, claim });
313
317
  }, [spec, cropSpec, emit, fail]);
318
+ // A picked, captured, or recorded file is validated (type, meta, duration,
319
+ // dimensions, crop) before it is emitted. The trigger shows pending for that
320
+ // whole window, so a second pick cannot race the first.
321
+ const handleSelected = useCallback(async (file, previewUrl, action = undefined) => {
322
+ setValidating(true);
323
+ try {
324
+ await validateAndEmit(file, previewUrl, action);
325
+ }
326
+ finally {
327
+ setValidating(false);
328
+ }
329
+ }, [validateAndEmit]);
314
330
  const onPick = useCallback(async (e) => {
315
331
  const file = e.target.files?.[0];
316
332
  e.target.value = "";
@@ -355,10 +371,13 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
355
371
  const isPreparing = phase === 'preparing' && isLoading;
356
372
  const isFinalizing = phase === 'finalizing' && isLoading;
357
373
  const sizeThreshold = progressBarMinBytes ?? DEFAULT_PROGRESS_BAR_MIN_BYTES;
374
+ // Consumers usually pass selectedFile={null} once the file is handed off, so
375
+ // the bar judges the size of the file this input emitted.
376
+ const trackedSize = selectedFile?.size ?? emittedSize;
358
377
  const showRealBar = isUploading &&
359
378
  typeof percent === 'number' &&
360
- !!selectedFile &&
361
- selectedFile.size >= sizeThreshold;
379
+ trackedSize !== null &&
380
+ trackedSize >= sizeThreshold;
362
381
  const canPick = spec.client?.allowPick ?? true;
363
382
  const canPhoto = spec.client?.allowCapturePhoto ?? true;
364
383
  const canRecVideo = spec.client?.allowRecordVideo ?? true;
@@ -408,13 +427,13 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
408
427
  const actions = useMemo(() => {
409
428
  const list = [];
410
429
  if (canPick)
411
- list.push({ kind: "pick", label: "Choose file", icon: _jsx(Upload, { className: "mr-2 icon-xs" }) });
430
+ list.push({ kind: "pick", label: "Choose file", icon: _jsx(Upload, { className: "icon-xs" }) });
412
431
  if (canPhoto)
413
- list.push({ kind: "photo", label: "Take photo", icon: _jsx(Camera, { className: "mr-2 icon-xs" }) });
432
+ list.push({ kind: "photo", label: "Take photo", icon: _jsx(Camera, { className: "icon-xs" }) });
414
433
  if (canRecVideo)
415
- list.push({ kind: "recordVideo", label: "Record video", icon: _jsx(Video, { className: "mr-2 icon-xs" }) });
434
+ list.push({ kind: "recordVideo", label: "Record video", icon: _jsx(Video, { className: "icon-xs" }) });
416
435
  if (canRecAudio)
417
- list.push({ kind: "recordAudio", label: "Record audio", icon: _jsx(Mic, { className: "mr-2 icon-xs" }) });
436
+ list.push({ kind: "recordAudio", label: "Record audio", icon: _jsx(Mic, { className: "icon-xs" }) });
418
437
  return list;
419
438
  }, [canPick, canPhoto, canRecVideo, canRecAudio]);
420
439
  /** Label & icon for the single trigger button. */
@@ -422,8 +441,8 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
422
441
  if (actions.length === 1)
423
442
  return actions[0].icon;
424
443
  if (buttonLabel !== "Add media")
425
- return _jsx(Upload, { className: "mr-2 icon-xs" });
426
- return _jsx(Plus, { className: "mr-2 icon-xs" });
444
+ return _jsx(Upload, { className: "icon-xs" });
445
+ return _jsx(Plus, { className: "icon-xs" });
427
446
  }, [actions, buttonLabel]);
428
447
  const triggerLabel = useMemo(() => {
429
448
  // If the consumer explicitly set a custom buttonLabel, always respect it.
@@ -449,16 +468,16 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
449
468
  * Never touches the hidden input directly, so none of the gates are bypassed.
450
469
  */
451
470
  const openSelection = useCallback(() => {
452
- if (disabled || isLoading || actions.length === 0)
471
+ if (disabled || isLoading || validating || actions.length === 0)
453
472
  return;
454
473
  if (actions.length === 1) {
455
474
  void runAction(actions[0].kind);
456
475
  return;
457
476
  }
458
477
  setMenuOpen(true);
459
- }, [disabled, isLoading, actions, runAction]);
478
+ }, [disabled, isLoading, validating, actions, runAction]);
460
479
  useImperativeHandle(ref, () => ({ openSelection }), [openSelection]);
461
- return (_jsxs(Card, { className: cn("p-3", className), children: [_jsx(Input, { id: `media-pick-${id}`, ref: pickerRef, type: "file", accept: acceptAttr, className: "hidden", onChange: onPick, disabled: disabled || isLoading }), selectedFile && !isLoading ? (_jsxs("div", { className: "mb-2 space-y-2", children: [previewKind === "image" || previewKind === "video" || previewKind === "audio" ? (_jsx("div", { className: cn("relative w-full rounded-md bg-muted", previewKind === "audio" ? "p-2" : "h-48 overflow-hidden"), children: _jsx(MediaPreview, { url: selectedFile, type: previewKind, mime: selectedFile.type, controls: true, className: previewKind === "audio" ? "w-full" : "h-full w-full" }) })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm truncate flex-1", children: selectedFileLabel }), onClear && (_jsx(Button, { variant: "ghost", size: "icon", className: "icon-sm hover:bg-destructive/20 shrink-0", onClick: onClear, children: _jsx(X, { className: "icon-xs" }) }))] })] })) : null, isLoading ? (_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Loader2, { className: "icon-xs spinner-xs" }), _jsx("span", { className: "text-sm text-muted-foreground", children: isPreparing
480
+ return (_jsxs(Card, { className: cn("p-3", className), children: [_jsx(Input, { id: `media-pick-${id}`, ref: pickerRef, type: "file", accept: acceptAttr, className: "hidden", onChange: onPick, disabled: disabled || isLoading || validating }), selectedFile && !isLoading ? (_jsxs("div", { className: "mb-2 space-y-2", children: [previewKind === "image" || previewKind === "video" || previewKind === "audio" ? (_jsx("div", { className: cn("relative w-full rounded-md bg-muted", previewKind === "audio" ? "p-2" : "h-48 overflow-hidden"), children: _jsx(MediaPreview, { url: selectedFile, type: previewKind, mime: selectedFile.type, controls: true, className: previewKind === "audio" ? "w-full" : "h-full w-full" }) })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm truncate flex-1", children: selectedFileLabel }), onClear && (_jsx(Button, { variant: "ghost", size: "icon", className: "icon-sm hover:bg-destructive/20 shrink-0", onClick: onClear, children: _jsx(X, { className: "icon-xs" }) }))] })] })) : null, isLoading ? (_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Spinner, { size: "xs" }), _jsx("span", { className: "text-sm text-muted-foreground", children: isPreparing
462
481
  ? "Preparing..."
463
482
  : isUploading && showRealBar
464
483
  ? `Uploading ${Math.round(percent ?? 0)}%...`
@@ -468,9 +487,9 @@ export const MediaInput = forwardRef(function MediaInput(props, ref) {
468
487
  ? "Finalizing..."
469
488
  : "Finalizing..." }), (isPreparing || isUploading) && onCancel ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "icon-sm hover:bg-destructive/20 shrink-0 ml-auto", onClick: onCancel, "aria-label": "Cancel upload", children: _jsx(X, { className: "icon-xs" }) })) : null] })) : null, showRealBar ? _jsx(Progress, { value: percent ?? 0, className: "mb-2 h-1.5" }) : null, _jsxs("div", { className: "flex flex-wrap gap-2", children: [actions.length <= 1 ? (
470
489
  /* Single action (or none): plain button, no dropdown */
471
- _jsxs(Button, { variant: "default", onClick: handleTriggerClick, disabled: disabled || isLoading || actions.length === 0, children: [triggerIcon, triggerLabel] })) : (
490
+ _jsx(Button, { variant: "default", onClick: handleTriggerClick, disabled: disabled || isLoading || actions.length === 0, pending: validating, icon: triggerIcon, children: triggerLabel })) : (
472
491
  /* Multiple actions: dropdown menu */
473
- _jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "default", disabled: disabled || isLoading, children: [triggerIcon, triggerLabel] }) }), _jsx(DropdownMenuContent, { align: "start", children: actions.map((a) => (_jsxs(DropdownMenuItem, { onClick: () => void runAction(a.kind), children: [a.icon, a.label] }, a.kind))) })] })), showInfoToggle ? (_jsxs(Button, { variant: "outline", onClick: () => setShowInfo((v) => !v), disabled: disabled || isLoading, children: [_jsx(Info, { className: "mr-2 icon-xs" }), showInfo ? "Hide info" : "Info"] })) : null] }), showInfo && showInfoToggle ? (_jsxs("div", { className: "mt-3 space-y-2", children: [infoNotes.length > 0 ? (_jsx("ul", { className: "text-xs text-muted-foreground list-disc pl-4 space-y-1", children: infoNotes.map((n, i) => (_jsx("li", { children: n }, i))) })) : null, specHasConstraints ? _jsx(MediaConstraintsHint, { spec: spec }) : null] })) : null, localError && (_jsx(Alert, { variant: "destructive", className: "mt-3", children: _jsx(AlertDescription, { children: localError.message }) })), cropSpec && (_jsx(ImageCropperModal, { isOpen: cropOpen, onClose: () => {
492
+ _jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "default", disabled: disabled || isLoading, pending: validating, icon: triggerIcon, children: triggerLabel }) }), _jsx(DropdownMenuContent, { align: "start", children: actions.map((a) => (_jsx(DropdownMenuItem, { onClick: () => void runAction(a.kind), icon: a.icon, children: a.label }, a.kind))) })] })), showInfoToggle ? (_jsxs(Button, { variant: "outline", onClick: () => setShowInfo((v) => !v), disabled: disabled || isLoading, children: [_jsx(Info, { className: "mr-2 icon-xs" }), showInfo ? "Hide info" : "Info"] })) : null] }), showInfo && showInfoToggle ? (_jsxs("div", { className: "mt-3 space-y-2", children: [infoNotes.length > 0 ? (_jsx("ul", { className: "text-xs text-muted-foreground list-disc pl-4 space-y-1", children: infoNotes.map((n, i) => (_jsx("li", { children: n }, i))) })) : null, specHasConstraints ? _jsx(MediaConstraintsHint, { spec: spec }) : null] })) : null, localError && (_jsx(Alert, { variant: "destructive", className: "mt-3", children: _jsx(AlertDescription, { children: localError.message }) })), cropSpec && (_jsx(ImageCropperModal, { isOpen: cropOpen, onClose: () => {
474
493
  setCropOpen(false);
475
494
  setCropSrc(null);
476
495
  }, imageSrc: cropSrc, aspectRatio: cropSpec.aspectRatio, shape: cropSpec.shape ?? "rect", outputWidth: cropSpec.outputWidth, outputHeight: cropSpec.outputHeight, onCropComplete: onCropComplete })), _jsx(PhotoCaptureModal, { open: photoOpen, facingMode: spec.client?.cameraFacingMode ?? "user", onCapture: onPhotoCapture, onClose: () => setPhotoOpen(false) }), _jsx(RecordDialog, { open: recordOpen, onOpenChange: setRecordOpen, initialKind: recordKind, canPhoto: canPhoto, canRecVideo: canRecVideo, canRecAudio: canRecAudio, cameraFacingMode: spec.client?.cameraFacingMode, maxRecordDurationSec: spec.client?.maxRecordDurationSec, disabled: disabled, isLoading: isLoading, onRecorded: async (file, url, recordedKind) => {