@wipco/sdui 1.8.2 → 1.8.3

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.
@@ -6,6 +6,98 @@ import { useState, useRef, useEffect, useCallback } from 'react';
6
6
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
7
 
8
8
  var THUMB_SIZE = 72;
9
+ var FILE_TILE_WIDTH = 168;
10
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
11
+ "jpg",
12
+ "jpeg",
13
+ "jpe",
14
+ "jfif",
15
+ "pjpeg",
16
+ "pjp",
17
+ "png",
18
+ "apng",
19
+ "gif",
20
+ "webp",
21
+ "avif",
22
+ "bmp",
23
+ "svg",
24
+ "ico",
25
+ "heic",
26
+ "heif",
27
+ "tif",
28
+ "tiff"
29
+ ]);
30
+ var MIME_SHAPE = /^[a-z0-9][\w.+-]*\/[\w.+-]+/i;
31
+ function urlOf(item) {
32
+ return typeof item.url === "string" ? item.url : "";
33
+ }
34
+ function itemMime(item) {
35
+ const record = item;
36
+ for (const key of ["mimeType", "mime_type", "type"]) {
37
+ const value = record[key];
38
+ if (typeof value === "string" && MIME_SHAPE.test(value.trim())) return value.trim().toLowerCase();
39
+ }
40
+ return null;
41
+ }
42
+ function extensionOfPath(path) {
43
+ const segment = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
44
+ const dot = segment.lastIndexOf(".");
45
+ if (dot <= 0) return null;
46
+ const ext = segment.slice(dot + 1).toLowerCase();
47
+ return /^[a-z0-9]{1,10}$/.test(ext) ? ext : null;
48
+ }
49
+ function extensionOfUrl(url) {
50
+ if (/^(blob|data):/i.test(url.trim())) return null;
51
+ return extensionOfPath(url.split(/[?#]/, 1)[0]);
52
+ }
53
+ function dataUrlMime(url) {
54
+ const match = /^data:([^;,]+)/i.exec(url.trim());
55
+ return match && MIME_SHAPE.test(match[1]) ? match[1].toLowerCase() : null;
56
+ }
57
+ function itemLooksLikeImage(item, fileType) {
58
+ const mime = itemMime(item) ?? (fileType && MIME_SHAPE.test(fileType) ? fileType.toLowerCase() : null) ?? dataUrlMime(urlOf(item));
59
+ if (mime) return mime.startsWith("image/");
60
+ const ext = extensionOfUrl(urlOf(item)) ?? (item.name ? extensionOfPath(item.name) : null);
61
+ if (ext) return IMAGE_EXTENSIONS.has(ext);
62
+ return true;
63
+ }
64
+ function fileBadgeOf(item, fileType) {
65
+ const ext = (item.name ? extensionOfPath(item.name) : null) ?? extensionOfUrl(urlOf(item));
66
+ if (ext) return ext.toUpperCase();
67
+ const mime = itemMime(item) ?? (fileType && MIME_SHAPE.test(fileType) ? fileType.toLowerCase() : null);
68
+ const subtype = mime?.split("/")[1]?.split(/[;+]/)[0];
69
+ return subtype && /^[a-z0-9]{1,5}$/.test(subtype) ? subtype.toUpperCase() : null;
70
+ }
71
+ function displayNameOf(item) {
72
+ if (item.name && item.name.trim() !== "") return item.name;
73
+ const url = urlOf(item);
74
+ if (!/^(blob|data):/i.test(url.trim())) {
75
+ const path = url.split(/[?#]/, 1)[0];
76
+ const segment = path.slice(path.lastIndexOf("/") + 1);
77
+ if (segment !== "") {
78
+ try {
79
+ return decodeURIComponent(segment);
80
+ } catch {
81
+ return segment;
82
+ }
83
+ }
84
+ }
85
+ return "\uCCA8\uBD80 \uD30C\uC77C";
86
+ }
87
+ function safeFileHref(url) {
88
+ const value = url.replace(/[\u0000-\u0020]/g, "");
89
+ if (value === "") return null;
90
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value);
91
+ if (!scheme) return url;
92
+ return /^(https?|blob)$/i.test(scheme[1]) ? url : null;
93
+ }
94
+ function acceptIsImageOnly(accept) {
95
+ const rules = accept.split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean);
96
+ if (rules.length === 0) return false;
97
+ return rules.every(
98
+ (rule) => rule.startsWith(".") ? IMAGE_EXTENSIONS.has(rule.slice(1)) : rule.startsWith("image/")
99
+ );
100
+ }
9
101
  function matchesAccept(file, accept) {
10
102
  const rules = accept.split(",").map((rule) => rule.trim().toLowerCase()).filter(Boolean);
11
103
  if (rules.length === 0) return true;
@@ -65,6 +157,10 @@ function FileDropzoneImpl({
65
157
  const inputRef = useRef(null);
66
158
  const dragDepthRef = useRef(0);
67
159
  const createdUrlsRef = useRef(/* @__PURE__ */ new Set());
160
+ const fileTypesRef = useRef(/* @__PURE__ */ new Map());
161
+ const rememberFileType = (id, file) => {
162
+ if (file.type) fileTypesRef.current.set(id, file.type);
163
+ };
68
164
  useEffect(() => {
69
165
  const created = createdUrlsRef.current;
70
166
  return () => created.forEach((url) => URL.revokeObjectURL(url));
@@ -93,6 +189,7 @@ function FileDropzoneImpl({
93
189
  try {
94
190
  const item = await upload(file);
95
191
  if (item && typeof item.id === "string" && typeof item.url === "string") {
192
+ rememberFileType(item.id, file);
96
193
  commit([...itemsRef.current, { name: file.name, ...item }]);
97
194
  } else {
98
195
  failures.push(`${file.name} (\uC5C5\uB85C\uB354\uAC00 \uD56D\uBAA9\uC744 \uC8FC\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4)`);
@@ -116,10 +213,11 @@ function FileDropzoneImpl({
116
213
  } else {
117
214
  const json = await res.json().catch(() => null);
118
215
  const body = unwrapUploaded(json);
119
- const id = body?.id;
216
+ const id2 = body?.id;
120
217
  const url2 = body?.url;
121
- if ((typeof id === "string" || typeof id === "number") && typeof url2 === "string") {
122
- commit([...itemsRef.current, { id: String(id), url: url2, name: file.name }]);
218
+ if ((typeof id2 === "string" || typeof id2 === "number") && typeof url2 === "string") {
219
+ rememberFileType(String(id2), file);
220
+ commit([...itemsRef.current, { id: String(id2), url: url2, name: file.name }]);
123
221
  } else {
124
222
  failures.push(`${file.name} (\uC751\uB2F5 \uD615\uC2DD \uC624\uB958)`);
125
223
  }
@@ -132,7 +230,9 @@ function FileDropzoneImpl({
132
230
  }
133
231
  const url = URL.createObjectURL(file);
134
232
  createdUrlsRef.current.add(url);
135
- commit([...itemsRef.current, { id: localId(), url, name: file.name }]);
233
+ const id = localId();
234
+ rememberFileType(id, file);
235
+ commit([...itemsRef.current, { id, url, name: file.name }]);
136
236
  }
137
237
  } finally {
138
238
  setProgress(null);
@@ -177,6 +277,7 @@ function FileDropzoneImpl({
177
277
  (item) => {
178
278
  const forget = () => {
179
279
  commit(itemsRef.current.filter((current) => current.id !== item.id));
280
+ fileTypesRef.current.delete(item.id);
180
281
  if (createdUrlsRef.current.has(item.url)) {
181
282
  URL.revokeObjectURL(item.url);
182
283
  createdUrlsRef.current.delete(item.url);
@@ -208,6 +309,7 @@ function FileDropzoneImpl({
208
309
  };
209
310
  const showLocalCaption = localMode || items.some((item) => item.id.startsWith("local-"));
210
311
  const dropTinted = canAdd && (dragOver || hovering);
312
+ const imageOnly = acceptIsImageOnly(accept);
211
313
  return /* @__PURE__ */ jsxs("div", { className: "wp-col", style: { gap: "var(--wp-space-2)" }, children: [
212
314
  /* @__PURE__ */ jsx(
213
315
  "input",
@@ -231,7 +333,7 @@ function FileDropzoneImpl({
231
333
  role: "button",
232
334
  tabIndex: canAdd ? 0 : -1,
233
335
  "aria-disabled": !canAdd,
234
- "aria-label": "\uC774\uBBF8\uC9C0 \uCCA8\uBD80",
336
+ "aria-label": imageOnly ? "\uC774\uBBF8\uC9C0 \uCCA8\uBD80" : "\uD30C\uC77C \uCCA8\uBD80",
235
337
  onClick: openPicker,
236
338
  onKeyDown: (event) => {
237
339
  if (canAdd && (event.key === "Enter" || event.key === " ")) {
@@ -284,7 +386,7 @@ function FileDropzoneImpl({
284
386
  style: { color: dropTinted ? "var(--wp-text-brand)" : "var(--wp-text-muted)" }
285
387
  }
286
388
  ),
287
- /* @__PURE__ */ jsx("span", { className: "wp-body-sm", style: { color: "var(--wp-text-default)" }, children: atCapacity ? `\uCD5C\uB300 ${maxCount}\uAC1C\uAE4C\uC9C0 \uCCA8\uBD80\uD588\uC2B5\uB2C8\uB2E4` : "\uD074\uB9AD\uD558\uAC70\uB098 \uC774\uBBF8\uC9C0\uB97C \uB04C\uC5B4\uB2E4 \uB193\uC73C\uC138\uC694" }),
389
+ /* @__PURE__ */ jsx("span", { className: "wp-body-sm", style: { color: "var(--wp-text-default)" }, children: atCapacity ? `\uCD5C\uB300 ${maxCount}\uAC1C\uAE4C\uC9C0 \uCCA8\uBD80\uD588\uC2B5\uB2C8\uB2E4` : `\uD074\uB9AD\uD558\uAC70\uB098 ${imageOnly ? "\uC774\uBBF8\uC9C0\uB97C" : "\uD30C\uC77C\uC744"} \uB04C\uC5B4\uB2E4 \uB193\uC73C\uC138\uC694` }),
288
390
  hint ? /* @__PURE__ */ jsx("span", { className: "wp-caption", children: hint }) : null
289
391
  ] })
290
392
  }
@@ -318,47 +420,128 @@ function FileDropzoneImpl({
318
420
  }
319
421
  ) : null,
320
422
  items.length > 0 ? /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: "var(--wp-space-2)" }, children: items.map((item) => {
321
- const thumbSrc = resolveAsset(item.url, {
423
+ const fileType = fileTypesRef.current.get(item.id);
424
+ const asImage = itemLooksLikeImage(item, fileType);
425
+ const resolved = resolveAsset(item.url, {
322
426
  component: "FileDropzone",
323
- prop: "src",
427
+ prop: asImage ? "src" : "href",
324
428
  kind: "attribute"
325
429
  });
430
+ const hovered = hoverItemId === item.id;
431
+ let preview;
432
+ if (asImage) {
433
+ preview = resolved === null ? /* @__PURE__ */ jsx(
434
+ "span",
435
+ {
436
+ "aria-label": item.name ?? "\uCCA8\uBD80 \uBBF8\uB9AC\uBCF4\uAE30 \uC5C6\uC74C",
437
+ role: "img",
438
+ style: {
439
+ display: "block",
440
+ width: THUMB_SIZE,
441
+ height: THUMB_SIZE,
442
+ borderRadius: "var(--wp-radius-md)",
443
+ border: "var(--wp-border-w) solid var(--wp-border-subtle)",
444
+ background: "var(--wp-surface-sunken)"
445
+ }
446
+ }
447
+ ) : /* @__PURE__ */ jsx(
448
+ ImageLightbox,
449
+ {
450
+ src: resolved,
451
+ alt: item.name,
452
+ thumbStyle: {
453
+ width: THUMB_SIZE,
454
+ height: THUMB_SIZE,
455
+ objectFit: "cover",
456
+ display: "block",
457
+ borderRadius: "var(--wp-radius-md)",
458
+ border: "var(--wp-border-w) solid var(--wp-border-subtle)"
459
+ }
460
+ }
461
+ );
462
+ } else {
463
+ const href = sandbox || resolved === null ? null : safeFileHref(resolved);
464
+ const displayName = displayNameOf(item);
465
+ const badge = fileBadgeOf(item, fileType);
466
+ const tileStyle = {
467
+ display: "flex",
468
+ alignItems: "center",
469
+ gap: "var(--wp-space-2)",
470
+ boxSizing: "border-box",
471
+ width: FILE_TILE_WIDTH,
472
+ height: THUMB_SIZE,
473
+ padding: "0 var(--wp-space-2)",
474
+ borderRadius: "var(--wp-radius-md)",
475
+ border: `var(--wp-border-w) solid ${hovered && href ? "var(--wp-border-strong)" : "var(--wp-border-subtle)"}`,
476
+ background: "var(--wp-surface-sunken)",
477
+ color: "var(--wp-text-default)",
478
+ textDecoration: "none",
479
+ transition: "var(--wp-transition-surface)"
480
+ };
481
+ const tileBody = /* @__PURE__ */ jsxs(Fragment, { children: [
482
+ /* @__PURE__ */ jsxs(
483
+ "span",
484
+ {
485
+ style: {
486
+ display: "flex",
487
+ flexDirection: "column",
488
+ alignItems: "center",
489
+ gap: 2,
490
+ flex: "none",
491
+ minWidth: 28,
492
+ color: "var(--wp-text-muted)"
493
+ },
494
+ children: [
495
+ /* @__PURE__ */ jsx(Icon, { name: "file-text", size: 24 }),
496
+ badge ? /* @__PURE__ */ jsx("span", { className: "wp-overline", style: { lineHeight: 1 }, children: badge }) : null
497
+ ]
498
+ }
499
+ ),
500
+ /* @__PURE__ */ jsx(
501
+ "span",
502
+ {
503
+ className: "wp-caption",
504
+ style: {
505
+ flex: 1,
506
+ minWidth: 0,
507
+ color: "var(--wp-text-default)",
508
+ display: "-webkit-box",
509
+ WebkitLineClamp: 2,
510
+ WebkitBoxOrient: "vertical",
511
+ overflow: "hidden",
512
+ wordBreak: "break-all"
513
+ },
514
+ children: displayName
515
+ }
516
+ )
517
+ ] });
518
+ preview = href ? /* @__PURE__ */ jsx(
519
+ "a",
520
+ {
521
+ href,
522
+ target: "_blank",
523
+ rel: "noopener",
524
+ title: displayName,
525
+ "aria-label": `${displayName}${badge ? ` (${badge})` : ""} \u2014 \uC0C8 \uD0ED\uC5D0\uC11C \uC5F4\uAE30`,
526
+ "data-file-tile": "",
527
+ style: tileStyle,
528
+ onClick: (event) => event.stopPropagation(),
529
+ children: tileBody
530
+ }
531
+ ) : /* @__PURE__ */ jsx("span", { title: displayName, "data-file-tile": "", style: tileStyle, children: tileBody });
532
+ }
326
533
  return /* @__PURE__ */ jsxs(
327
534
  "div",
328
535
  {
329
- style: { position: "relative", width: THUMB_SIZE, height: THUMB_SIZE },
536
+ style: {
537
+ position: "relative",
538
+ width: asImage ? THUMB_SIZE : FILE_TILE_WIDTH,
539
+ height: THUMB_SIZE
540
+ },
330
541
  onMouseEnter: () => setHoverItemId(item.id),
331
542
  onMouseLeave: () => setHoverItemId((current) => current === item.id ? null : current),
332
543
  children: [
333
- thumbSrc === null ? /* @__PURE__ */ jsx(
334
- "span",
335
- {
336
- "aria-label": item.name ?? "\uCCA8\uBD80 \uBBF8\uB9AC\uBCF4\uAE30 \uC5C6\uC74C",
337
- role: "img",
338
- style: {
339
- display: "block",
340
- width: THUMB_SIZE,
341
- height: THUMB_SIZE,
342
- borderRadius: "var(--wp-radius-md)",
343
- border: "var(--wp-border-w) solid var(--wp-border-subtle)",
344
- background: "var(--wp-surface-sunken)"
345
- }
346
- }
347
- ) : /* @__PURE__ */ jsx(
348
- ImageLightbox,
349
- {
350
- src: thumbSrc,
351
- alt: item.name,
352
- thumbStyle: {
353
- width: THUMB_SIZE,
354
- height: THUMB_SIZE,
355
- objectFit: "cover",
356
- display: "block",
357
- borderRadius: "var(--wp-radius-md)",
358
- border: "var(--wp-border-w) solid var(--wp-border-subtle)"
359
- }
360
- }
361
- ),
544
+ preview,
362
545
  /* @__PURE__ */ jsx(
363
546
  "span",
364
547
  {
@@ -369,7 +552,7 @@ function FileDropzoneImpl({
369
552
  background: "var(--wp-surface-card)",
370
553
  borderRadius: "var(--wp-radius-sm)",
371
554
  boxShadow: "var(--wp-shadow-sm)",
372
- opacity: hoverItemId === item.id ? 1 : 0,
555
+ opacity: hovered ? 1 : 0,
373
556
  transition: "opacity var(--wp-duration-fast) var(--wp-ease-out)"
374
557
  },
375
558
  children: /* @__PURE__ */ jsx(
package/dist/meta.js CHANGED
@@ -4376,7 +4376,7 @@ var FileDropzoneMeta = {
4376
4376
  label: "\uD30C\uC77C \uCCA8\uBD80",
4377
4377
  category: "input",
4378
4378
  icon: "paperclip",
4379
- description: "\uB4DC\uB798\uADF8 \uC564 \uB4DC\uB86D \uB2E4\uC911 \uCCA8\uBD80. \uC5C5\uB85C\uB4DC \uACBD\uB85C\uAC00 \uC5C6\uAC70\uB098 \uBC31\uC5D4\uB4DC\uAC00 503\uC774\uBA74 object URL \uB85C\uCEEC \uBBF8\uB9AC\uBCF4\uAE30\uB85C \uB5A8\uC5B4\uC9C4\uB2E4.",
4379
+ description: "\uB4DC\uB798\uADF8 \uC564 \uB4DC\uB86D \uB2E4\uC911 \uCCA8\uBD80. \uC774\uBBF8\uC9C0\uB294 \uC378\uB124\uC77C, PDF \uB4F1 \uADF8 \uBC16\uC758 \uD30C\uC77C\uC740 \uC0C8 \uD0ED\uC73C\uB85C \uC5EC\uB294 \uD30C\uC77C \uD0C0\uC77C\uB85C \uBCF4\uC778\uB2E4. \uC5C5\uB85C\uB4DC \uACBD\uB85C\uAC00 \uC5C6\uAC70\uB098 \uBC31\uC5D4\uB4DC\uAC00 503\uC774\uBA74 object URL \uB85C\uCEEC \uBBF8\uB9AC\uBCF4\uAE30\uB85C \uB5A8\uC5B4\uC9C4\uB2E4.",
4380
4380
  props: [
4381
4381
  {
4382
4382
  name: "value",
@@ -4389,7 +4389,13 @@ var FileDropzoneMeta = {
4389
4389
  item: [
4390
4390
  { name: "id", kind: "string", label: "\uC2DD\uBCC4\uC790", required: true },
4391
4391
  { name: "url", kind: "string", label: "URL", required: true },
4392
- { name: "name", kind: "string", label: "\uD30C\uC77C\uBA85" }
4392
+ { name: "name", kind: "string", label: "\uD30C\uC77C\uBA85" },
4393
+ {
4394
+ name: "mimeType",
4395
+ kind: "string",
4396
+ label: "MIME \uD615\uC2DD",
4397
+ description: "image/\u2026 \uC774\uBA74 \uC378\uB124\uC77C, \uC544\uB2C8\uBA74 \uD30C\uC77C \uD0C0\uC77C(\uBB38\uC11C \uC544\uC774\uCF58\xB7\uD655\uC7A5\uC790\xB7\uD30C\uC77C\uBA85, \uB204\uB974\uBA74 \uC0C8 \uD0ED). mime_type\xB7type \uB3C4 \uC77D\uACE0, \uC5C6\uC73C\uBA74 URL\xB7\uD30C\uC77C\uBA85 \uD655\uC7A5\uC790\uB85C \uD310\uC815\uD55C\uB2E4."
4398
+ }
4393
4399
  ]
4394
4400
  },
4395
4401
  {
@@ -5,6 +5,14 @@ type FileDropzoneItem = {
5
5
  id: string;
6
6
  url: string;
7
7
  name?: string;
8
+ /**
9
+ * MIME 형식(예: `application/pdf`) — 있으면 이미지/파일 타일 판정의 1순위다.
10
+ * 서버 행을 그대로 채우는 앱은 snake_case(`mime_type`)로 주고, File 같은 객체는
11
+ * `type`으로 준다 — 셋 다 읽는다(`type`은 `x/y` 모양일 때만 MIME 으로 본다).
12
+ */
13
+ mimeType?: string;
14
+ mime_type?: string;
15
+ type?: string;
8
16
  };
9
17
  interface FileDropzoneProps {
10
18
  /** 백엔드 업로드 경로 (예: "/attachments"). 없으면 로컬 미리보기 모드 */
@@ -1,4 +1,4 @@
1
- export { FileDropzone, unwrapUploaded } from '../chunk-IWLLM45X.js';
1
+ export { FileDropzone, unwrapUploaded } from '../chunk-CFFSCGDV.js';
2
2
  import '../chunk-JDTVNS3H.js';
3
3
  import '../chunk-PN5IJPA5.js';
4
4
  import '../chunk-R2ORCOTG.js';
@@ -59,7 +59,7 @@ import { Disclosure } from '../chunk-LONXOEZY.js';
59
59
  export { Disclosure } from '../chunk-LONXOEZY.js';
60
60
  import { EventCalendar } from '../chunk-KVQXQLAP.js';
61
61
  export { EventCalendar, statusToneOf } from '../chunk-KVQXQLAP.js';
62
- export { FileDropzone, unwrapUploaded } from '../chunk-IWLLM45X.js';
62
+ export { FileDropzone, unwrapUploaded } from '../chunk-CFFSCGDV.js';
63
63
  import { ImageLightbox } from '../chunk-JDTVNS3H.js';
64
64
  export { ImageLightbox } from '../chunk-JDTVNS3H.js';
65
65
  import { InlineEdit } from '../chunk-FXEKHHEA.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipco/sdui",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "description": "WIPCO 서버 주도 UI(SDUI) 렌더러 — JSON 페이지 스펙을 React 컴포넌트로 렌더링",
5
5
  "keywords": [
6
6
  "sdui",