@comitor/ui 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/shell/contrast-provider.d.ts +10 -2
- package/dist/shell/contrast-provider.js +12 -4
- package/dist/shell/contrast-provider.js.map +1 -1
- package/dist/shell/display-axis-provider.d.ts +30 -4
- package/dist/shell/display-axis-provider.js +12 -4
- package/dist/shell/display-axis-provider.js.map +1 -1
- package/dist/uploader/file-upload-constants.d.ts +27 -0
- package/dist/uploader/file-upload-constants.js +11 -0
- package/dist/uploader/file-upload-constants.js.map +1 -0
- package/dist/uploader/file-upload.d.ts +69 -0
- package/dist/uploader/file-upload.js +181 -0
- package/dist/uploader/file-upload.js.map +1 -0
- package/dist/uploader/index.d.ts +3 -0
- package/dist/uploader/index.js +7 -0
- package/dist/uploader/index.js.map +1 -0
- package/package.json +28 -15
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ pnpm add -D tailwindcss @tailwindcss/postcss
|
|
|
37
37
|
pnpm add next next-themes # cho @comitor/ui/shell
|
|
38
38
|
pnpm add recharts # cho @comitor/ui/chart
|
|
39
39
|
pnpm add react-hook-form # cho @comitor/ui/form
|
|
40
|
+
pnpm add @uppy/core @uppy/xhr-upload # cho @comitor/ui/uploader
|
|
40
41
|
```
|
|
41
42
|
|
|
42
43
|
| Peer | Range | Bắt buộc | Cần khi |
|
|
@@ -103,6 +104,7 @@ Package có **5 entry**, tách theo peer optional mà mỗi tầng kéo theo:
|
|
|
103
104
|
| `@comitor/ui` | Tier 1 (primitive) + Tier 2 (composite) + `cn` + token TS + `useIsMac` | **không có** — chạy được ở Vite, Storybook, test runner |
|
|
104
105
|
| `@comitor/ui/shell` | Tier 3: khung ứng dụng workspace × app, theme, ba trục hiển thị | `next`, `next-themes` |
|
|
105
106
|
| `@comitor/ui/chart` | Lớp shadcn bọc recharts + `Comitor*Chart` + `CHART_COLORS` | `recharts` (~100KB) |
|
|
107
|
+
| `@comitor/ui/uploader` | `FileUpload` — kéo thả + tiến độ, Uppy chạy headless | `@uppy/core`, `@uppy/xhr-upload` (~13KB gzip) |
|
|
106
108
|
| `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | `react-hook-form` |
|
|
107
109
|
| `@comitor/ui/tokens` | Token dạng object TS — cho nơi không có Tailwind: canvas, email HTML, PDF | không có |
|
|
108
110
|
|
|
@@ -14,6 +14,13 @@ interface ContrastProviderProps {
|
|
|
14
14
|
defaultContrast?: ContrastMode;
|
|
15
15
|
/** Khoá localStorage. Đổi khi một app muốn tách lựa chọn khỏi các app khác cùng tên miền. */
|
|
16
16
|
storageKey?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Nonce của CSP, cho `<ContrastScript>` mà provider này TỰ render.
|
|
19
|
+
*
|
|
20
|
+
* Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ không mang thuộc tính `nonce`,
|
|
21
|
+
* đúng hành vi trước đây. Xem `ContrastScript` để biết vì sao nó chỉ phát ở phía máy chủ.
|
|
22
|
+
*/
|
|
23
|
+
nonce?: string;
|
|
17
24
|
}
|
|
18
25
|
/**
|
|
19
26
|
* Trục thứ HAI của giao diện, độc lập với sáng/tối: chọn BẢNG MÀU.
|
|
@@ -40,7 +47,7 @@ interface ContrastProviderProps {
|
|
|
40
47
|
* `<html>` phải có `suppressHydrationWarning` (cùng lý do với ThemeProvider: script sửa DOM
|
|
41
48
|
* trước khi React hydrate nên HTML client luôn khác HTML server).
|
|
42
49
|
*/
|
|
43
|
-
declare function ContrastProvider({ children, defaultContrast, storageKey }: ContrastProviderProps): React$1.JSX.Element;
|
|
50
|
+
declare function ContrastProvider({ children, defaultContrast, storageKey, nonce }: ContrastProviderProps): React$1.JSX.Element;
|
|
44
51
|
/**
|
|
45
52
|
* Script chống nháy. `<ContrastProvider>` đã render sẵn nó nên **app thường không cần gọi tay** —
|
|
46
53
|
* export ra chỉ cho trường hợp app tự dựng provider hoặc cần đặt nó ở một vị trí khác trong body.
|
|
@@ -48,8 +55,9 @@ declare function ContrastProvider({ children, defaultContrast, storageKey }: Con
|
|
|
48
55
|
* ⚠ ĐỪNG đặt vào `<head>`: React 19 báo lỗi console ở mọi lần render client (xem chú thích của
|
|
49
56
|
* `ContrastProvider`). Gọi hai lần cũng vô hại — script idempotent — chỉ tốn một thẻ.
|
|
50
57
|
*/
|
|
51
|
-
declare function ContrastScript({ storageKey }: {
|
|
58
|
+
declare function ContrastScript({ storageKey, nonce }: {
|
|
52
59
|
storageKey?: string;
|
|
60
|
+
nonce?: string;
|
|
53
61
|
}): React$1.JSX.Element;
|
|
54
62
|
/**
|
|
55
63
|
* Đọc/đổi bảng màu. Ném lỗi nếu gọi ngoài `<ContrastProvider>` — im lặng trả mặc định sẽ khiến
|
|
@@ -6,7 +6,8 @@ const ContrastContext = createContext(null);
|
|
|
6
6
|
function ContrastProvider({
|
|
7
7
|
children,
|
|
8
8
|
defaultContrast = "normal",
|
|
9
|
-
storageKey = CONTRAST_STORAGE_KEY
|
|
9
|
+
storageKey = CONTRAST_STORAGE_KEY,
|
|
10
|
+
nonce
|
|
10
11
|
}) {
|
|
11
12
|
const [contrast, setContrastState] = useState(defaultContrast);
|
|
12
13
|
const [mounted, setMounted] = useState(false);
|
|
@@ -40,13 +41,20 @@ function ContrastProvider({
|
|
|
40
41
|
[contrast, setContrast, mounted]
|
|
41
42
|
);
|
|
42
43
|
return /* @__PURE__ */ jsxs(ContrastContext.Provider, { value, children: [
|
|
43
|
-
/* @__PURE__ */ jsx(ContrastScript, { storageKey }),
|
|
44
|
+
/* @__PURE__ */ jsx(ContrastScript, { storageKey, nonce }),
|
|
44
45
|
children
|
|
45
46
|
] });
|
|
46
47
|
}
|
|
47
|
-
function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY }) {
|
|
48
|
+
function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY, nonce }) {
|
|
48
49
|
const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v==="high")document.documentElement.setAttribute(${JSON.stringify(CONTRAST_ATTRIBUTE)},"high")}catch(e){}})();`;
|
|
49
|
-
return /* @__PURE__ */ jsx(
|
|
50
|
+
return /* @__PURE__ */ jsx(
|
|
51
|
+
"script",
|
|
52
|
+
{
|
|
53
|
+
suppressHydrationWarning: true,
|
|
54
|
+
nonce: typeof window === "undefined" ? nonce : "",
|
|
55
|
+
dangerouslySetInnerHTML: { __html: script }
|
|
56
|
+
}
|
|
57
|
+
);
|
|
50
58
|
}
|
|
51
59
|
function useContrast() {
|
|
52
60
|
const ctx = useContext(ContrastContext);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/contrast-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { CONTRAST_ATTRIBUTE, CONTRAST_STORAGE_KEY, type ContrastMode, isContrastMode } from \"./contrast-constants.js\";\n\nexport interface ContrastContextValue {\n /** Chế độ đang áp dụng. Trước khi mount luôn là giá trị mặc định, không phải giá trị đã lưu. */\n contrast: ContrastMode;\n setContrast: (mode: ContrastMode) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration, xem ContrastToggle. */\n mounted: boolean;\n}\n\nconst ContrastContext = createContext<ContrastContextValue | null>(null);\n\nexport interface ContrastProviderProps {\n children: React.ReactNode;\n /** Bảng màu khi người dùng chưa chọn gì. Mặc định `normal` (comitor-ds). */\n defaultContrast?: ContrastMode;\n /** Khoá localStorage. Đổi khi một app muốn tách lựa chọn khỏi các app khác cùng tên miền. */\n storageKey?: string;\n}\n\n/**\n * Trục thứ HAI của giao diện, độc lập với sáng/tối: chọn BẢNG MÀU.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` CHO TRỤC NÀY ─\n * `next-themes` giữ **một context singleton** cho toàn cây. Lồng hai `<ThemeProvider>` với\n * `attribute`/`storageKey` khác nhau thì provider trong cùng thắng và `useTheme()` của app đọc\n * nhầm trục — không có lỗi biên dịch nào, chỉ có nút đổi sáng/tối lặng lẽ ngừng ăn. Trục tương\n * phản đơn giản hơn theme (không có \"system\", không đọc `prefers-color-scheme`) nên tự viết\n * ~60 dòng là đủ và không phải trả cái giá đó.\n *\n * ─ CHỐNG NHÁY ─\n * Bảng màu phải có mặt **trước** lần sơn đầu tiên, nếu không người bật tương phản cao sẽ thấy một\n * khung hình bảng mặc định rồi mới đổi. Effect của React chạy SAU khi sơn, nên phải là một script\n * đồng bộ — provider TỰ render nó (`<ContrastScript />`) làm con đầu tiên, app không phải làm gì.\n *\n * ⚠ Script nằm trong `<body>`, KHÔNG phải `<head>` — và đó là chỗ duy nhất đúng. Đặt vào `<head>`\n * thì React 19 kêu \"Encountered a script tag while rendering React component\" ở mỗi lần render\n * client (script inline không thuộc nhóm phần tử React hoisted được), tức console đỏ ở mọi trang.\n * Trong `<body>` thì không, và về chống nháy vẫn tương đương: trình duyệt chạy script ngay khi\n * parser đi tới nó, mà nó đứng TRƯỚC toàn bộ markup của app. Đây cũng đúng chỗ next-themes đặt\n * script của nó.\n *\n * `<html>` phải có `suppressHydrationWarning` (cùng lý do với ThemeProvider: script sửa DOM\n * trước khi React hydrate nên HTML client luôn khác HTML server).\n */\nexport function ContrastProvider({\n children,\n defaultContrast = \"normal\",\n storageKey = CONTRAST_STORAGE_KEY\n}: ContrastProviderProps) {\n const [contrast, setContrastState] = useState<ContrastMode>(defaultContrast);\n const [mounted, setMounted] = useState(false);\n\n // Đọc lựa chọn đã lưu SAU khi mount. Trước đó state phải bằng đúng giá trị server đã render,\n // nếu không React báo lệch hydration — DOM thật đã được ContrastScript đặt đúng từ trước rồi.\n useEffect(() => {\n let stored: string | null = null;\n try {\n stored = window.localStorage.getItem(storageKey);\n } catch {\n // Safari chế độ riêng tư / localStorage bị chặn: rơi về mặc định, không phải lỗi.\n }\n if (isContrastMode(stored)) setContrastState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n if (contrast === \"normal\") root.removeAttribute(CONTRAST_ATTRIBUTE);\n else root.setAttribute(CONTRAST_ATTRIBUTE, contrast);\n }, [contrast, mounted]);\n\n const setContrast = useCallback(\n (mode: ContrastMode) => {\n setContrastState(mode);\n try {\n window.localStorage.setItem(storageKey, mode);\n } catch {\n // Không lưu được thì lựa chọn chỉ sống trong phiên này — vẫn tốt hơn là ném lỗi.\n }\n },\n [storageKey]\n );\n\n const value = useMemo<ContrastContextValue>(\n () => ({ contrast, setContrast, mounted }),\n [contrast, setContrast, mounted]\n );\n\n return (\n <ContrastContext.Provider value={value}>\n <ContrastScript storageKey={storageKey} />\n {children}\n </ContrastContext.Provider>\n );\n}\n\n/**\n * Script chống nháy. `<ContrastProvider>` đã render sẵn nó nên **app thường không cần gọi tay** —\n * export ra chỉ cho trường hợp app tự dựng provider hoặc cần đặt nó ở một vị trí khác trong body.\n *\n * ⚠ ĐỪNG đặt vào `<head>`: React 19 báo lỗi console ở mọi lần render client (xem chú thích của\n * `ContrastProvider`). Gọi hai lần cũng vô hại — script idempotent — chỉ tốn một thẻ.\n */\nexport function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY }: { storageKey?: string }) {\n // Chuỗi tự gọi, không phụ thuộc React: chạy ngay lúc trình duyệt gặp thẻ <script>.\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v===\"high\")document.documentElement.setAttribute(${JSON.stringify(CONTRAST_ATTRIBUTE)},\"high\")}catch(e){}})();`;\n // biome-ignore lint/security/noDangerouslySetInnerHtml: chuỗi hằng do chính package sinh, không có dữ liệu người dùng.\n
|
|
1
|
+
{"version":3,"sources":["../../src/shell/contrast-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { CONTRAST_ATTRIBUTE, CONTRAST_STORAGE_KEY, type ContrastMode, isContrastMode } from \"./contrast-constants.js\";\n\nexport interface ContrastContextValue {\n /** Chế độ đang áp dụng. Trước khi mount luôn là giá trị mặc định, không phải giá trị đã lưu. */\n contrast: ContrastMode;\n setContrast: (mode: ContrastMode) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration, xem ContrastToggle. */\n mounted: boolean;\n}\n\nconst ContrastContext = createContext<ContrastContextValue | null>(null);\n\nexport interface ContrastProviderProps {\n children: React.ReactNode;\n /** Bảng màu khi người dùng chưa chọn gì. Mặc định `normal` (comitor-ds). */\n defaultContrast?: ContrastMode;\n /** Khoá localStorage. Đổi khi một app muốn tách lựa chọn khỏi các app khác cùng tên miền. */\n storageKey?: string;\n /**\n * Nonce của CSP, cho `<ContrastScript>` mà provider này TỰ render.\n *\n * Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ không mang thuộc tính `nonce`,\n * đúng hành vi trước đây. Xem `ContrastScript` để biết vì sao nó chỉ phát ở phía máy chủ.\n */\n nonce?: string;\n}\n\n/**\n * Trục thứ HAI của giao diện, độc lập với sáng/tối: chọn BẢNG MÀU.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` CHO TRỤC NÀY ─\n * `next-themes` giữ **một context singleton** cho toàn cây. Lồng hai `<ThemeProvider>` với\n * `attribute`/`storageKey` khác nhau thì provider trong cùng thắng và `useTheme()` của app đọc\n * nhầm trục — không có lỗi biên dịch nào, chỉ có nút đổi sáng/tối lặng lẽ ngừng ăn. Trục tương\n * phản đơn giản hơn theme (không có \"system\", không đọc `prefers-color-scheme`) nên tự viết\n * ~60 dòng là đủ và không phải trả cái giá đó.\n *\n * ─ CHỐNG NHÁY ─\n * Bảng màu phải có mặt **trước** lần sơn đầu tiên, nếu không người bật tương phản cao sẽ thấy một\n * khung hình bảng mặc định rồi mới đổi. Effect của React chạy SAU khi sơn, nên phải là một script\n * đồng bộ — provider TỰ render nó (`<ContrastScript />`) làm con đầu tiên, app không phải làm gì.\n *\n * ⚠ Script nằm trong `<body>`, KHÔNG phải `<head>` — và đó là chỗ duy nhất đúng. Đặt vào `<head>`\n * thì React 19 kêu \"Encountered a script tag while rendering React component\" ở mỗi lần render\n * client (script inline không thuộc nhóm phần tử React hoisted được), tức console đỏ ở mọi trang.\n * Trong `<body>` thì không, và về chống nháy vẫn tương đương: trình duyệt chạy script ngay khi\n * parser đi tới nó, mà nó đứng TRƯỚC toàn bộ markup của app. Đây cũng đúng chỗ next-themes đặt\n * script của nó.\n *\n * `<html>` phải có `suppressHydrationWarning` (cùng lý do với ThemeProvider: script sửa DOM\n * trước khi React hydrate nên HTML client luôn khác HTML server).\n */\nexport function ContrastProvider({\n children,\n defaultContrast = \"normal\",\n storageKey = CONTRAST_STORAGE_KEY,\n nonce\n}: ContrastProviderProps) {\n const [contrast, setContrastState] = useState<ContrastMode>(defaultContrast);\n const [mounted, setMounted] = useState(false);\n\n // Đọc lựa chọn đã lưu SAU khi mount. Trước đó state phải bằng đúng giá trị server đã render,\n // nếu không React báo lệch hydration — DOM thật đã được ContrastScript đặt đúng từ trước rồi.\n useEffect(() => {\n let stored: string | null = null;\n try {\n stored = window.localStorage.getItem(storageKey);\n } catch {\n // Safari chế độ riêng tư / localStorage bị chặn: rơi về mặc định, không phải lỗi.\n }\n if (isContrastMode(stored)) setContrastState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n if (contrast === \"normal\") root.removeAttribute(CONTRAST_ATTRIBUTE);\n else root.setAttribute(CONTRAST_ATTRIBUTE, contrast);\n }, [contrast, mounted]);\n\n const setContrast = useCallback(\n (mode: ContrastMode) => {\n setContrastState(mode);\n try {\n window.localStorage.setItem(storageKey, mode);\n } catch {\n // Không lưu được thì lựa chọn chỉ sống trong phiên này — vẫn tốt hơn là ném lỗi.\n }\n },\n [storageKey]\n );\n\n const value = useMemo<ContrastContextValue>(\n () => ({ contrast, setContrast, mounted }),\n [contrast, setContrast, mounted]\n );\n\n return (\n <ContrastContext.Provider value={value}>\n <ContrastScript storageKey={storageKey} nonce={nonce} />\n {children}\n </ContrastContext.Provider>\n );\n}\n\n/**\n * Script chống nháy. `<ContrastProvider>` đã render sẵn nó nên **app thường không cần gọi tay** —\n * export ra chỉ cho trường hợp app tự dựng provider hoặc cần đặt nó ở một vị trí khác trong body.\n *\n * ⚠ ĐỪNG đặt vào `<head>`: React 19 báo lỗi console ở mọi lần render client (xem chú thích của\n * `ContrastProvider`). Gọi hai lần cũng vô hại — script idempotent — chỉ tốn một thẻ.\n */\nexport function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY, nonce }: { storageKey?: string; nonce?: string }) {\n // Chuỗi tự gọi, không phụ thuộc React: chạy ngay lúc trình duyệt gặp thẻ <script>.\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v===\"high\")document.documentElement.setAttribute(${JSON.stringify(CONTRAST_ATTRIBUTE)},\"high\")}catch(e){}})();`;\n return (\n <script\n suppressHydrationWarning\n /*\n * ⚠ `nonce` CHỈ phát ở phía MÁY CHỦ. Khuôn chép của `next-themes`, hai lý do thật:\n * 1. Trình duyệt CỐ Ý giấu giá trị `nonce` — `getAttribute(\"nonce\")` trả chuỗi RỖNG sau khi\n * trang tải xong, để một XSS đọc được DOM cũng không lấy được nonce. Render nonce thật ở\n * client thì React so với chuỗi rỗng đó và báo lệch hydration ở MỌI trang.\n * 2. Nonce chỉ có tác dụng ở lượt HTML đầu tiên, mà script này cũng chỉ chạy ở lượt đó.\n */\n nonce={typeof window === \"undefined\" ? nonce : \"\"}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: chuỗi hằng do chính package sinh, không có dữ liệu người dùng.\n dangerouslySetInnerHTML={{ __html: script }}\n />\n );\n}\n\n/**\n * Đọc/đổi bảng màu. Ném lỗi nếu gọi ngoài `<ContrastProvider>` — im lặng trả mặc định sẽ khiến\n * nút gạt trông như hỏng mà không có gì chỉ ra nguyên nhân.\n */\nexport function useContrast(): ContrastContextValue {\n const ctx = useContext(ContrastContext);\n if (!ctx) throw new Error(\"useContrast() phải nằm trong <ContrastProvider>. Bọc nó ở root layout.\");\n return ctx;\n}\n"],"mappings":";AAsGI,SACE,KADF;AApGJ,SAAS,eAAe,aAAa,YAAY,WAAW,SAAS,gBAAgB;AACrF,SAAS,oBAAoB,sBAAyC,sBAAsB;AAU5F,MAAM,kBAAkB,cAA2C,IAAI;AA0ChE,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA,kBAAkB;AAAA,EAClB,aAAa;AAAA,EACb;AACF,GAA0B;AACxB,QAAM,CAAC,UAAU,gBAAgB,IAAI,SAAuB,eAAe;AAC3E,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAI5C,YAAU,MAAM;AACd,QAAI,SAAwB;AAC5B,QAAI;AACF,eAAS,OAAO,aAAa,QAAQ,UAAU;AAAA,IACjD,QAAQ;AAAA,IAER;AACA,QAAI,eAAe,MAAM,EAAG,kBAAiB,MAAM;AACnD,eAAW,IAAI;AAAA,EACjB,GAAG,CAAC,UAAU,CAAC;AAEf,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,SAAS;AACtB,QAAI,aAAa,SAAU,MAAK,gBAAgB,kBAAkB;AAAA,QAC7D,MAAK,aAAa,oBAAoB,QAAQ;AAAA,EACrD,GAAG,CAAC,UAAU,OAAO,CAAC;AAEtB,QAAM,cAAc;AAAA,IAClB,CAAC,SAAuB;AACtB,uBAAiB,IAAI;AACrB,UAAI;AACF,eAAO,aAAa,QAAQ,YAAY,IAAI;AAAA,MAC9C,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,IACA,CAAC,UAAU;AAAA,EACb;AAEA,QAAM,QAAQ;AAAA,IACZ,OAAO,EAAE,UAAU,aAAa,QAAQ;AAAA,IACxC,CAAC,UAAU,aAAa,OAAO;AAAA,EACjC;AAEA,SACE,qBAAC,gBAAgB,UAAhB,EAAyB,OACxB;AAAA,wBAAC,kBAAe,YAAwB,OAAc;AAAA,IACrD;AAAA,KACH;AAEJ;AASO,SAAS,eAAe,EAAE,aAAa,sBAAsB,MAAM,GAA4C;AAEpH,QAAM,SAAS,8CAA8C,KAAK,UAAU,UAAU,CAAC,yDAAyD,KAAK,UAAU,kBAAkB,CAAC;AAClL,SACE;AAAA,IAAC;AAAA;AAAA,MACC,0BAAwB;AAAA,MAQxB,OAAO,OAAO,WAAW,cAAc,QAAQ;AAAA,MAE/C,yBAAyB,EAAE,QAAQ,OAAO;AAAA;AAAA,EAC5C;AAEJ;AAMO,SAAS,cAAoC;AAClD,QAAM,MAAM,WAAW,eAAe;AACtC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,+FAAwE;AAClG,SAAO;AACT;","names":[]}
|
|
@@ -8,13 +8,26 @@ interface DisplayAxisValue<T extends string> {
|
|
|
8
8
|
/** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */
|
|
9
9
|
mounted: boolean;
|
|
10
10
|
}
|
|
11
|
-
declare const DensityProvider: ({ children, defaultValue, storageKey }: {
|
|
11
|
+
declare const DensityProvider: ({ children, defaultValue, storageKey, nonce }: {
|
|
12
12
|
children: ReactNode;
|
|
13
13
|
defaultValue?: "compact" | "comfortable" | undefined;
|
|
14
14
|
storageKey?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Nonce của CSP, cho script chống nháy mà provider này TỰ render.
|
|
17
|
+
*
|
|
18
|
+
* Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ `<script>` không mang thuộc
|
|
19
|
+
* tính `nonce`, đúng hành vi trước đây.
|
|
20
|
+
*
|
|
21
|
+
* Đường lấy giá trị (Next 16): `proxy.ts` sinh nonce và đặt nó lên header REQUEST cùng chính
|
|
22
|
+
* sách CSP; root layout đọc bằng `(await headers()).get("x-nonce")`. Next TỰ gắn nonce cho mọi
|
|
23
|
+
* script của CHÍNH NÓ, nhưng script do gói này render thì React 19 **không** gắn hộ — nên nó
|
|
24
|
+
* bắt buộc phải đi qua prop.
|
|
25
|
+
*/
|
|
26
|
+
nonce?: string;
|
|
15
27
|
}) => React.JSX.Element;
|
|
16
|
-
declare const DensityScript: ({ storageKey }: {
|
|
28
|
+
declare const DensityScript: ({ storageKey, nonce }: {
|
|
17
29
|
storageKey?: string;
|
|
30
|
+
nonce?: string;
|
|
18
31
|
}) => React.JSX.Element;
|
|
19
32
|
/** Đọc/đổi mật độ bố cục. */
|
|
20
33
|
declare function useDensity(): {
|
|
@@ -22,13 +35,26 @@ declare function useDensity(): {
|
|
|
22
35
|
setDensity: (m: DensityMode) => void;
|
|
23
36
|
mounted: boolean;
|
|
24
37
|
};
|
|
25
|
-
declare const FontSizeProvider: ({ children, defaultValue, storageKey }: {
|
|
38
|
+
declare const FontSizeProvider: ({ children, defaultValue, storageKey, nonce }: {
|
|
26
39
|
children: ReactNode;
|
|
27
40
|
defaultValue?: "sm" | "md" | "lg" | undefined;
|
|
28
41
|
storageKey?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Nonce của CSP, cho script chống nháy mà provider này TỰ render.
|
|
44
|
+
*
|
|
45
|
+
* Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ `<script>` không mang thuộc
|
|
46
|
+
* tính `nonce`, đúng hành vi trước đây.
|
|
47
|
+
*
|
|
48
|
+
* Đường lấy giá trị (Next 16): `proxy.ts` sinh nonce và đặt nó lên header REQUEST cùng chính
|
|
49
|
+
* sách CSP; root layout đọc bằng `(await headers()).get("x-nonce")`. Next TỰ gắn nonce cho mọi
|
|
50
|
+
* script của CHÍNH NÓ, nhưng script do gói này render thì React 19 **không** gắn hộ — nên nó
|
|
51
|
+
* bắt buộc phải đi qua prop.
|
|
52
|
+
*/
|
|
53
|
+
nonce?: string;
|
|
29
54
|
}) => React.JSX.Element;
|
|
30
|
-
declare const FontSizeScript: ({ storageKey }: {
|
|
55
|
+
declare const FontSizeScript: ({ storageKey, nonce }: {
|
|
31
56
|
storageKey?: string;
|
|
57
|
+
nonce?: string;
|
|
32
58
|
}) => React.JSX.Element;
|
|
33
59
|
/** Đọc/đổi cỡ chữ. */
|
|
34
60
|
declare function useFontSize(): {
|
|
@@ -13,14 +13,22 @@ import {
|
|
|
13
13
|
} from "./display-axis-constants.js";
|
|
14
14
|
function createDisplayAxis(config) {
|
|
15
15
|
const Context = createContext(null);
|
|
16
|
-
function AxisScript({ storageKey = config.storageKey }) {
|
|
16
|
+
function AxisScript({ storageKey = config.storageKey, nonce }) {
|
|
17
17
|
const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}catch(e){}})();`;
|
|
18
|
-
return /* @__PURE__ */ jsx(
|
|
18
|
+
return /* @__PURE__ */ jsx(
|
|
19
|
+
"script",
|
|
20
|
+
{
|
|
21
|
+
suppressHydrationWarning: true,
|
|
22
|
+
nonce: typeof window === "undefined" ? nonce : "",
|
|
23
|
+
dangerouslySetInnerHTML: { __html: script }
|
|
24
|
+
}
|
|
25
|
+
);
|
|
19
26
|
}
|
|
20
27
|
function Provider({
|
|
21
28
|
children,
|
|
22
29
|
defaultValue = config.defaultValue,
|
|
23
|
-
storageKey = config.storageKey
|
|
30
|
+
storageKey = config.storageKey,
|
|
31
|
+
nonce
|
|
24
32
|
}) {
|
|
25
33
|
const [value, setValueState] = useState(defaultValue);
|
|
26
34
|
const [mounted, setMounted] = useState(false);
|
|
@@ -51,7 +59,7 @@ function createDisplayAxis(config) {
|
|
|
51
59
|
);
|
|
52
60
|
const ctx = useMemo(() => ({ value, setValue, mounted }), [value, setValue, mounted]);
|
|
53
61
|
return /* @__PURE__ */ jsxs(Context.Provider, { value: ctx, children: [
|
|
54
|
-
/* @__PURE__ */ jsx(AxisScript, { storageKey }),
|
|
62
|
+
/* @__PURE__ */ jsx(AxisScript, { storageKey, nonce }),
|
|
55
63
|
children
|
|
56
64
|
] });
|
|
57
65
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/display-axis-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport {\n DEFAULT_DENSITY,\n DEFAULT_FONT_SIZE,\n DENSITY_ATTRIBUTE,\n DENSITY_STORAGE_KEY,\n type DensityMode,\n FONT_SIZE_ATTRIBUTE,\n FONT_SIZE_STORAGE_KEY,\n type FontSizeMode,\n isDensityMode,\n isFontSizeMode\n} from \"./display-axis-constants.js\";\n\n/**\n * Xưởng dựng TRỤC HIỂN THỊ — dùng chung cho mật độ và cỡ chữ.\n *\n * `ContrastProvider` viết tay ~60 dòng cho trục thứ hai. Trục thứ ba và thứ tư sẽ là hai bản chép\n * gần như y hệt, nên phần chung tách ra đây: cùng cơ chế đọc localStorage sau khi mount, cùng cờ\n * `mounted` chống lệch hydration, cùng script chống nháy trong `<body>`, cùng quy ước GỠ thuộc\n * tính ở giá trị mặc định.\n *\n * ⚠ `ContrastProvider` CỐ Ý chưa chuyển sang xưởng này. Nó là API đã phát hành và có hành vi\n * riêng đã được ghi chép; chuyển nó là một thay đổi có rủi ro mà lượt này không cần tới. Nếu sau\n * này chuyển thì chuyển một mình nó, có bản so đầu ra trước/sau.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` ─\n * Nó giữ MỘT context singleton cho cả cây. Lồng thêm provider với `attribute`/`storageKey` khác\n * thì provider trong cùng thắng và `useTheme()` của app đọc nhầm trục — không lỗi biên dịch, chỉ\n * là nút đổi sáng/tối lặng lẽ ngừng ăn. `contrast-provider.tsx` đã ghi lại đúng cạm bẫy này.\n */\ninterface AxisConfig<T extends string> {\n attribute: string;\n storageKey: string;\n defaultValue: T;\n isValue: (v: unknown) => v is T;\n hookName: string;\n providerName: string;\n}\n\nexport interface DisplayAxisValue<T extends string> {\n value: T;\n setValue: (next: T) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */\n mounted: boolean;\n}\n\nfunction createDisplayAxis<T extends string>(config: AxisConfig<T>) {\n const Context = createContext<DisplayAxisValue<T> | null>(null);\n\n /**\n * Script chống nháy. Phải chạy TRƯỚC lần sơn đầu tiên, nếu không người đã chọn `lg` sẽ thấy một\n * khung hình cỡ mặc định rồi mới nhảy. Effect của React chạy SAU khi sơn nên không dùng được.\n *\n * ⚠ Nằm trong `<body>`, KHÔNG phải `<head>`: React 19 kêu \"Encountered a script tag while\n * rendering React component\" ở mọi lần render client nếu đặt vào head. Trong body thì tương\n * đương về chống nháy vì parser chạy script ngay khi gặp, mà nó đứng trước toàn bộ markup.\n */\n function AxisScript({ storageKey = config.storageKey }: { storageKey?: string }) {\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}catch(e){}})();`;\n // biome-ignore lint/security/noDangerouslySetInnerHtml: chuỗi hằng do chính package sinh, không có dữ liệu người dùng.\n return <script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: script }} />;\n }\n\n function Provider({\n children,\n defaultValue = config.defaultValue,\n storageKey = config.storageKey\n }: {\n children: ReactNode;\n defaultValue?: T;\n storageKey?: string;\n }) {\n const [value, setValueState] = useState<T>(defaultValue);\n const [mounted, setMounted] = useState(false);\n\n // Đọc lựa chọn đã lưu SAU khi mount. Trước đó state phải bằng đúng giá trị server đã render,\n // nếu không React báo lệch hydration — DOM thật đã được AxisScript đặt đúng từ trước rồi.\n useEffect(() => {\n let stored: string | null = null;\n try {\n stored = window.localStorage.getItem(storageKey);\n } catch {\n // Safari chế độ riêng tư / localStorage bị chặn: rơi về mặc định, không phải lỗi.\n }\n if (config.isValue(stored)) setValueState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n // Mặc định thì GỠ thuộc tính, không ghi giá trị — xem ghi chú cuối display-axis-constants.ts.\n if (value === config.defaultValue) root.removeAttribute(config.attribute);\n else root.setAttribute(config.attribute, value);\n }, [value, mounted]);\n\n const setValue = useCallback(\n (next: T) => {\n setValueState(next);\n try {\n window.localStorage.setItem(storageKey, next);\n } catch {\n // Không lưu được thì lựa chọn chỉ sống trong phiên này — vẫn tốt hơn là ném lỗi.\n }\n },\n [storageKey]\n );\n\n const ctx = useMemo<DisplayAxisValue<T>>(() => ({ value, setValue, mounted }), [value, setValue, mounted]);\n\n return (\n <Context.Provider value={ctx}>\n <AxisScript storageKey={storageKey} />\n {children}\n </Context.Provider>\n );\n }\n\n function useAxis(): DisplayAxisValue<T> {\n const ctx = useContext(Context);\n // Ném lỗi thay vì trả mặc định: im lặng thì control trông như hỏng mà không chỉ ra nguyên nhân.\n if (!ctx) throw new Error(`${config.hookName}() phải nằm trong <${config.providerName}>. Bọc nó ở root layout.`);\n return ctx;\n }\n\n return { Provider, useAxis, AxisScript };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 3 — MẬT ĐỘ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst densityAxis = createDisplayAxis<DensityMode>({\n attribute: DENSITY_ATTRIBUTE,\n storageKey: DENSITY_STORAGE_KEY,\n defaultValue: DEFAULT_DENSITY,\n isValue: isDensityMode,\n hookName: \"useDensity\",\n providerName: \"DensityProvider\"\n});\n\nexport const DensityProvider = densityAxis.Provider;\nexport const DensityScript = densityAxis.AxisScript;\n\n/** Đọc/đổi mật độ bố cục. */\nexport function useDensity(): { density: DensityMode; setDensity: (m: DensityMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = densityAxis.useAxis();\n return { density: value, setDensity: setValue, mounted };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 4 — CỠ CHỮ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst fontSizeAxis = createDisplayAxis<FontSizeMode>({\n attribute: FONT_SIZE_ATTRIBUTE,\n storageKey: FONT_SIZE_STORAGE_KEY,\n defaultValue: DEFAULT_FONT_SIZE,\n isValue: isFontSizeMode,\n hookName: \"useFontSize\",\n providerName: \"FontSizeProvider\"\n});\n\nexport const FontSizeProvider = fontSizeAxis.Provider;\nexport const FontSizeScript = fontSizeAxis.AxisScript;\n\n/** Đọc/đổi cỡ chữ. */\nexport function useFontSize(): { fontSize: FontSizeMode; setFontSize: (m: FontSizeMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = fontSizeAxis.useAxis();\n return { fontSize: value, setFontSize: setValue, mounted };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/display-axis-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport {\n DEFAULT_DENSITY,\n DEFAULT_FONT_SIZE,\n DENSITY_ATTRIBUTE,\n DENSITY_STORAGE_KEY,\n type DensityMode,\n FONT_SIZE_ATTRIBUTE,\n FONT_SIZE_STORAGE_KEY,\n type FontSizeMode,\n isDensityMode,\n isFontSizeMode\n} from \"./display-axis-constants.js\";\n\n/**\n * Xưởng dựng TRỤC HIỂN THỊ — dùng chung cho mật độ và cỡ chữ.\n *\n * `ContrastProvider` viết tay ~60 dòng cho trục thứ hai. Trục thứ ba và thứ tư sẽ là hai bản chép\n * gần như y hệt, nên phần chung tách ra đây: cùng cơ chế đọc localStorage sau khi mount, cùng cờ\n * `mounted` chống lệch hydration, cùng script chống nháy trong `<body>`, cùng quy ước GỠ thuộc\n * tính ở giá trị mặc định.\n *\n * ⚠ `ContrastProvider` CỐ Ý chưa chuyển sang xưởng này. Nó là API đã phát hành và có hành vi\n * riêng đã được ghi chép; chuyển nó là một thay đổi có rủi ro mà lượt này không cần tới. Nếu sau\n * này chuyển thì chuyển một mình nó, có bản so đầu ra trước/sau.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` ─\n * Nó giữ MỘT context singleton cho cả cây. Lồng thêm provider với `attribute`/`storageKey` khác\n * thì provider trong cùng thắng và `useTheme()` của app đọc nhầm trục — không lỗi biên dịch, chỉ\n * là nút đổi sáng/tối lặng lẽ ngừng ăn. `contrast-provider.tsx` đã ghi lại đúng cạm bẫy này.\n */\ninterface AxisConfig<T extends string> {\n attribute: string;\n storageKey: string;\n defaultValue: T;\n isValue: (v: unknown) => v is T;\n hookName: string;\n providerName: string;\n}\n\nexport interface DisplayAxisValue<T extends string> {\n value: T;\n setValue: (next: T) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */\n mounted: boolean;\n}\n\nfunction createDisplayAxis<T extends string>(config: AxisConfig<T>) {\n const Context = createContext<DisplayAxisValue<T> | null>(null);\n\n /**\n * Script chống nháy. Phải chạy TRƯỚC lần sơn đầu tiên, nếu không người đã chọn `lg` sẽ thấy một\n * khung hình cỡ mặc định rồi mới nhảy. Effect của React chạy SAU khi sơn nên không dùng được.\n *\n * ⚠ Nằm trong `<body>`, KHÔNG phải `<head>`: React 19 kêu \"Encountered a script tag while\n * rendering React component\" ở mọi lần render client nếu đặt vào head. Trong body thì tương\n * đương về chống nháy vì parser chạy script ngay khi gặp, mà nó đứng trước toàn bộ markup.\n */\n function AxisScript({ storageKey = config.storageKey, nonce }: { storageKey?: string; nonce?: string }) {\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}catch(e){}})();`;\n return (\n <script\n suppressHydrationWarning\n /*\n * ⚠ `nonce` CHỈ phát ở phía MÁY CHỦ — `typeof window === \"undefined\" ? nonce : \"\"`.\n *\n * Khuôn này chép nguyên của `next-themes`, và nó có hai lý do thật chứ không phải thẩm mỹ:\n *\n * 1. **Trình duyệt CỐ Ý giấu giá trị `nonce`**: `el.getAttribute(\"nonce\")` trả về chuỗi\n * RỖNG sau khi trang tải xong. Đó là một chốt của chính CSP — để một XSS đọc được DOM\n * cũng không lấy được nonce rồi tự tiêm script hợp lệ. Render nonce thật ở client thì\n * React so nó với chuỗi rỗng của DOM và báo lệch hydration ở MỌI trang.\n * 2. Nonce chỉ có tác dụng ở lượt phản hồi HTML đầu tiên, mà script này cũng chỉ chạy ở\n * đúng lượt đó — sau khi hydrate xong nó không còn việc gì.\n */\n nonce={typeof window === \"undefined\" ? nonce : \"\"}\n // biome-ignore lint/security/noDangerouslySetInnerHtml: chuỗi hằng do chính package sinh, không có dữ liệu người dùng.\n dangerouslySetInnerHTML={{ __html: script }}\n />\n );\n }\n\n function Provider({\n children,\n defaultValue = config.defaultValue,\n storageKey = config.storageKey,\n nonce\n }: {\n children: ReactNode;\n defaultValue?: T;\n storageKey?: string;\n /**\n * Nonce của CSP, cho script chống nháy mà provider này TỰ render.\n *\n * Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ `<script>` không mang thuộc\n * tính `nonce`, đúng hành vi trước đây.\n *\n * Đường lấy giá trị (Next 16): `proxy.ts` sinh nonce và đặt nó lên header REQUEST cùng chính\n * sách CSP; root layout đọc bằng `(await headers()).get(\"x-nonce\")`. Next TỰ gắn nonce cho mọi\n * script của CHÍNH NÓ, nhưng script do gói này render thì React 19 **không** gắn hộ — nên nó\n * bắt buộc phải đi qua prop.\n */\n nonce?: string;\n }) {\n const [value, setValueState] = useState<T>(defaultValue);\n const [mounted, setMounted] = useState(false);\n\n // Đọc lựa chọn đã lưu SAU khi mount. Trước đó state phải bằng đúng giá trị server đã render,\n // nếu không React báo lệch hydration — DOM thật đã được AxisScript đặt đúng từ trước rồi.\n useEffect(() => {\n let stored: string | null = null;\n try {\n stored = window.localStorage.getItem(storageKey);\n } catch {\n // Safari chế độ riêng tư / localStorage bị chặn: rơi về mặc định, không phải lỗi.\n }\n if (config.isValue(stored)) setValueState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n // Mặc định thì GỠ thuộc tính, không ghi giá trị — xem ghi chú cuối display-axis-constants.ts.\n if (value === config.defaultValue) root.removeAttribute(config.attribute);\n else root.setAttribute(config.attribute, value);\n }, [value, mounted]);\n\n const setValue = useCallback(\n (next: T) => {\n setValueState(next);\n try {\n window.localStorage.setItem(storageKey, next);\n } catch {\n // Không lưu được thì lựa chọn chỉ sống trong phiên này — vẫn tốt hơn là ném lỗi.\n }\n },\n [storageKey]\n );\n\n const ctx = useMemo<DisplayAxisValue<T>>(() => ({ value, setValue, mounted }), [value, setValue, mounted]);\n\n return (\n <Context.Provider value={ctx}>\n <AxisScript storageKey={storageKey} nonce={nonce} />\n {children}\n </Context.Provider>\n );\n }\n\n function useAxis(): DisplayAxisValue<T> {\n const ctx = useContext(Context);\n // Ném lỗi thay vì trả mặc định: im lặng thì control trông như hỏng mà không chỉ ra nguyên nhân.\n if (!ctx) throw new Error(`${config.hookName}() phải nằm trong <${config.providerName}>. Bọc nó ở root layout.`);\n return ctx;\n }\n\n return { Provider, useAxis, AxisScript };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 3 — MẬT ĐỘ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst densityAxis = createDisplayAxis<DensityMode>({\n attribute: DENSITY_ATTRIBUTE,\n storageKey: DENSITY_STORAGE_KEY,\n defaultValue: DEFAULT_DENSITY,\n isValue: isDensityMode,\n hookName: \"useDensity\",\n providerName: \"DensityProvider\"\n});\n\nexport const DensityProvider = densityAxis.Provider;\nexport const DensityScript = densityAxis.AxisScript;\n\n/** Đọc/đổi mật độ bố cục. */\nexport function useDensity(): { density: DensityMode; setDensity: (m: DensityMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = densityAxis.useAxis();\n return { density: value, setDensity: setValue, mounted };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 4 — CỠ CHỮ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst fontSizeAxis = createDisplayAxis<FontSizeMode>({\n attribute: FONT_SIZE_ATTRIBUTE,\n storageKey: FONT_SIZE_STORAGE_KEY,\n defaultValue: DEFAULT_FONT_SIZE,\n isValue: isFontSizeMode,\n hookName: \"useFontSize\",\n providerName: \"FontSizeProvider\"\n});\n\nexport const FontSizeProvider = fontSizeAxis.Provider;\nexport const FontSizeScript = fontSizeAxis.AxisScript;\n\n/** Đọc/đổi cỡ chữ. */\nexport function useFontSize(): { fontSize: FontSizeMode; setFontSize: (m: FontSizeMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = fontSizeAxis.useAxis();\n return { fontSize: value, setFontSize: setValue, mounted };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nhãn của `FileUpload` — file RIÊNG, không `"use client"`.
|
|
3
|
+
*
|
|
4
|
+
* ⚠ `verify:directives` chặn việc khai hằng dữ liệu thuần trong một file `"use client"`: làm vậy
|
|
5
|
+
* thì mọi chỗ import hằng đó (kể cả Server Component chỉ cần đọc một chuỗi) bị kéo sang phía
|
|
6
|
+
* client. Đó là lý do file này tách ra, cùng khuôn với `data-table-constants.ts`.
|
|
7
|
+
*/
|
|
8
|
+
interface FileUploadLabels {
|
|
9
|
+
/** Chữ trong vùng kéo thả. */
|
|
10
|
+
dropHint: string;
|
|
11
|
+
/** Nhãn nút mở hộp chọn tệp. */
|
|
12
|
+
choose: string;
|
|
13
|
+
/** Nhãn nút và `aria-label` của thanh tiến độ khi đang tải. */
|
|
14
|
+
uploading: string;
|
|
15
|
+
/** Thông điệp dự phòng khi endpoint không trả `error.code` đọc được. */
|
|
16
|
+
uploadFailed: string;
|
|
17
|
+
/**
|
|
18
|
+
* Khi `@uppy/core` / `@uppy/xhr-upload` chưa được cài.
|
|
19
|
+
*
|
|
20
|
+
* Chúng là peer TUỲ CHỌN nên `import()` sẽ hỏng ở đúng lúc người dùng chọn tệp. Không có câu này
|
|
21
|
+
* thì thứ họ thấy là một `ChunkLoadError`, và người sửa đi tìm nguyên nhân ở webpack.
|
|
22
|
+
*/
|
|
23
|
+
missingDependency: string;
|
|
24
|
+
}
|
|
25
|
+
declare const DEFAULT_FILE_UPLOAD_LABELS: FileUploadLabels;
|
|
26
|
+
|
|
27
|
+
export { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLabels };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const DEFAULT_FILE_UPLOAD_LABELS = {
|
|
2
|
+
dropHint: "K\xE9o t\u1EC7p v\xE0o \u0111\xE2y, ho\u1EB7c",
|
|
3
|
+
choose: "Ch\u1ECDn t\u1EC7p",
|
|
4
|
+
uploading: "\u0110ang t\u1EA3i l\xEAn\u2026",
|
|
5
|
+
uploadFailed: "Kh\xF4ng t\u1EA3i l\xEAn \u0111\u01B0\u1EE3c. Th\u1EED l\u1EA1i.",
|
|
6
|
+
missingDependency: "Ch\u1EE9c n\u0103ng t\u1EA3i t\u1EC7p ch\u01B0a s\u1EB5n s\xE0ng: thi\u1EBFu g\xF3i @uppy/core v\xE0 @uppy/xhr-upload."
|
|
7
|
+
};
|
|
8
|
+
export {
|
|
9
|
+
DEFAULT_FILE_UPLOAD_LABELS
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=file-upload-constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/uploader/file-upload-constants.ts"],"sourcesContent":["/**\n * Nhãn của `FileUpload` — file RIÊNG, không `\"use client\"`.\n *\n * ⚠ `verify:directives` chặn việc khai hằng dữ liệu thuần trong một file `\"use client\"`: làm vậy\n * thì mọi chỗ import hằng đó (kể cả Server Component chỉ cần đọc một chuỗi) bị kéo sang phía\n * client. Đó là lý do file này tách ra, cùng khuôn với `data-table-constants.ts`.\n */\n\nexport interface FileUploadLabels {\n /** Chữ trong vùng kéo thả. */\n dropHint: string;\n /** Nhãn nút mở hộp chọn tệp. */\n choose: string;\n /** Nhãn nút và `aria-label` của thanh tiến độ khi đang tải. */\n uploading: string;\n /** Thông điệp dự phòng khi endpoint không trả `error.code` đọc được. */\n uploadFailed: string;\n /**\n * Khi `@uppy/core` / `@uppy/xhr-upload` chưa được cài.\n *\n * Chúng là peer TUỲ CHỌN nên `import()` sẽ hỏng ở đúng lúc người dùng chọn tệp. Không có câu này\n * thì thứ họ thấy là một `ChunkLoadError`, và người sửa đi tìm nguyên nhân ở webpack.\n */\n missingDependency: string;\n}\n\nexport const DEFAULT_FILE_UPLOAD_LABELS: FileUploadLabels = {\n dropHint: \"Kéo tệp vào đây, hoặc\",\n choose: \"Chọn tệp\",\n uploading: \"Đang tải lên…\",\n uploadFailed: \"Không tải lên được. Thử lại.\",\n missingDependency: \"Chức năng tải tệp chưa sẵn sàng: thiếu gói @uppy/core và @uppy/xhr-upload.\"\n};\n"],"mappings":"AA0BO,MAAM,6BAA+C;AAAA,EAC1D,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,cAAc;AAAA,EACd,mBAAmB;AACrB;","names":[]}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { FileUploadLabels } from './file-upload-constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ô tải tệp dùng chung cho mọi app Comitor — **Uppy chạy HEADLESS, giao diện là của gói này**.
|
|
6
|
+
*
|
|
7
|
+
* ── VÌ SAO KHÔNG DÙNG `@uppy/dashboard` HAY `@uppy/react` ──────────────────────────────────
|
|
8
|
+
* Cả hai đều kéo theo **Preact** và **một bộ CSS riêng**, tức app đã có React lại ship thêm một
|
|
9
|
+
* runtime giao diện thứ hai, và bảng màu của Uppy không đi qua token của Comitor nên nó KHÔNG qua
|
|
10
|
+
* được cổng tương phản (`pnpm lint:a11y`).
|
|
11
|
+
*
|
|
12
|
+
* Đo được trên một cây đã cài Uppy 5: `@uppy/dashboard` 1.236 KB, `@uppy/react` 432 KB, `preact`
|
|
13
|
+
* 1.852 KB trên đĩa. Trong khi `@uppy/core` khai `sideEffects: ["*.css"]` và chỉ có `UIPlugin.js`
|
|
14
|
+
* import `preact` — nên **chỉ nạp `Uppy` + `XHRUpload` thì bundler cắt được cả nhánh Preact**.
|
|
15
|
+
*
|
|
16
|
+
* Cái ta giữ lại của Uppy là phần đáng giá và khó viết đúng: hàng đợi, tiến độ theo byte, thử lại,
|
|
17
|
+
* huỷ, và bộ luật hạn chế (kiểu tệp / kích thước / số lượng) áp NGAY trên trình duyệt.
|
|
18
|
+
*
|
|
19
|
+
* ── VÌ SAO `XHRUpload` CHỨ KHÔNG PHẢI `AwsS3` (presigned PUT) ──────────────────────────────
|
|
20
|
+
* Một app Comitor khác (babrik) tải thẳng lên S3 bằng URL đã ký. Cách đó đúng cho một bucket CÔNG
|
|
21
|
+
* KHAI, nơi URL trả về chính là URL hiển thị. Comitor thì dùng **bucket RIÊNG TƯ**: database lưu
|
|
22
|
+
* KHOÁ object chứ không lưu URL, và URL hiển thị được sinh lại có chữ ký ở mỗi lần đọc.
|
|
23
|
+
*
|
|
24
|
+
* Hệ quả: máy chủ vẫn phải là nơi đặt tên khoá, kiểm byte thật, và ghi khoá vào database. Đẩy
|
|
25
|
+
* việc tải thẳng lên S3 sẽ lấy mất của máy chủ khả năng nhìn thấy nội dung tệp — với một IdP phục
|
|
26
|
+
* vụ ảnh do người dùng tải lên thì đó là đánh đổi không đáng.
|
|
27
|
+
*
|
|
28
|
+
* `XHRUpload` POST một `multipart/form-data` tới chính endpoint của app, nên **không route nào phải
|
|
29
|
+
* đổi** và không có bề mặt bảo mật mới nào mở ra.
|
|
30
|
+
*
|
|
31
|
+
* ── CSP ─────────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
* Uppy KHÔNG dùng `eval` hay `new Function` (đã grep `@uppy/core`, `@uppy/dashboard`, `@uppy/react`:
|
|
33
|
+
* 0 file). Nên nó chạy được dưới `script-src 'self' 'nonce-…'` mà không cần nới gì.
|
|
34
|
+
*/
|
|
35
|
+
/** Kết quả một tệp tải xong: thân phản hồi JSON của endpoint app. */
|
|
36
|
+
interface FileUploadResult<T = unknown> {
|
|
37
|
+
body: T;
|
|
38
|
+
fileName: string;
|
|
39
|
+
}
|
|
40
|
+
interface FileUploadProps<T = unknown> {
|
|
41
|
+
/** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */
|
|
42
|
+
endpoint: string;
|
|
43
|
+
/** Tên trường trong `FormData`. Mặc định `"file"` — khớp khuôn route của Comitor. */
|
|
44
|
+
fieldName?: string;
|
|
45
|
+
/** Giới hạn kích thước mỗi tệp, byte. */
|
|
46
|
+
maxFileSize: number;
|
|
47
|
+
/** MIME được nhận, ví dụ `["image/jpeg", "image/png", "image/webp"]`. */
|
|
48
|
+
allowedFileTypes: string[];
|
|
49
|
+
/** Số tệp tối đa mỗi lượt. Mặc định 1. */
|
|
50
|
+
maxNumberOfFiles?: number;
|
|
51
|
+
/** Chạy khi MỘT tệp tải xong. Nhận nguyên thân JSON của endpoint. */
|
|
52
|
+
onUploaded: (result: FileUploadResult<T>) => void;
|
|
53
|
+
/**
|
|
54
|
+
* Chạy khi tải hỏng. `code` là `error.code` từ thân phản hồi nếu endpoint trả theo khuôn
|
|
55
|
+
* `{ error: { code } }` của Comitor — nhờ đó chỗ gọi tra được bảng dịch của chính nó.
|
|
56
|
+
*/
|
|
57
|
+
onError?: (error: {
|
|
58
|
+
code?: string;
|
|
59
|
+
status?: number;
|
|
60
|
+
message: string;
|
|
61
|
+
}) => void;
|
|
62
|
+
/** Nhãn hiển thị. Mặc định tiếng Việt; truyền `Partial<>` để đổi từng chuỗi. */
|
|
63
|
+
labels?: Partial<FileUploadLabels>;
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
declare function FileUpload<T = unknown>({ endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, onUploaded, onError, labels, disabled, className }: FileUploadProps<T>): React.JSX.Element;
|
|
68
|
+
|
|
69
|
+
export { FileUpload, type FileUploadProps, type FileUploadResult };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { Button } from "../components/ui/button.js";
|
|
5
|
+
import { Progress } from "../components/ui/progress.js";
|
|
6
|
+
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { DEFAULT_FILE_UPLOAD_LABELS } from "./file-upload-constants.js";
|
|
8
|
+
async function loadUppy() {
|
|
9
|
+
try {
|
|
10
|
+
const [core, xhr] = await Promise.all([import("@uppy/core"), import("@uppy/xhr-upload")]);
|
|
11
|
+
return {
|
|
12
|
+
// `as unknown as` là bắt buộc: kiểu thật của Uppy có generic `<Meta, Body>` mà `UppyLike`
|
|
13
|
+
// cố ý không mang. Xem ghi chú ở `UppyLike` về việc vì sao gói này không dùng kiểu thật.
|
|
14
|
+
Uppy: core.default ?? core.Uppy,
|
|
15
|
+
XHRUpload: xhr.default ?? xhr
|
|
16
|
+
};
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function FileUpload({
|
|
22
|
+
endpoint,
|
|
23
|
+
fieldName = "file",
|
|
24
|
+
maxFileSize,
|
|
25
|
+
allowedFileTypes,
|
|
26
|
+
maxNumberOfFiles = 1,
|
|
27
|
+
onUploaded,
|
|
28
|
+
onError,
|
|
29
|
+
labels,
|
|
30
|
+
disabled = false,
|
|
31
|
+
className
|
|
32
|
+
}) {
|
|
33
|
+
const text = { ...DEFAULT_FILE_UPLOAD_LABELS, ...labels };
|
|
34
|
+
const inputId = useId();
|
|
35
|
+
const inputRef = useRef(null);
|
|
36
|
+
const [busy, setBusy] = useState(false);
|
|
37
|
+
const [percent, setPercent] = useState(0);
|
|
38
|
+
const [dragging, setDragging] = useState(false);
|
|
39
|
+
const handlers = useRef({ onUploaded, onError });
|
|
40
|
+
handlers.current = { onUploaded, onError };
|
|
41
|
+
const uppyRef = useRef(null);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
return () => {
|
|
44
|
+
uppyRef.current?.destroy();
|
|
45
|
+
uppyRef.current = null;
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
const start = useCallback(
|
|
49
|
+
async (files) => {
|
|
50
|
+
const list = Array.from(files).slice(0, maxNumberOfFiles);
|
|
51
|
+
if (list.length === 0) return;
|
|
52
|
+
setBusy(true);
|
|
53
|
+
setPercent(0);
|
|
54
|
+
try {
|
|
55
|
+
const mod = await loadUppy();
|
|
56
|
+
if (!mod) {
|
|
57
|
+
handlers.current.onError?.({ message: text.missingDependency });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
uppyRef.current?.destroy();
|
|
61
|
+
const uppy = new mod.Uppy({
|
|
62
|
+
autoProceed: false,
|
|
63
|
+
restrictions: { maxNumberOfFiles, maxFileSize, allowedFileTypes }
|
|
64
|
+
});
|
|
65
|
+
uppyRef.current = uppy;
|
|
66
|
+
const lastError = {};
|
|
67
|
+
uppy.use(mod.XHRUpload, {
|
|
68
|
+
endpoint,
|
|
69
|
+
fieldName,
|
|
70
|
+
method: "POST",
|
|
71
|
+
// Cookie phiên là cách Comitor xác thực mọi route tự viết — thiếu dòng này là 401.
|
|
72
|
+
withCredentials: true,
|
|
73
|
+
/*
|
|
74
|
+
* ⚠ CHỈ THỬ LẠI LỖI 5xx.
|
|
75
|
+
*
|
|
76
|
+
* Mặc định của Uppy là `shouldRetry = () => true` với `retries = 3` (đọc
|
|
77
|
+
* `@uppy/core/lib/utils/fetcher.js`), tức MỌI phản hồi không-2xx bị gửi lại ba lần. Hậu
|
|
78
|
+
* quả đo được bằng chính hợp đồng của Comitor:
|
|
79
|
+
* · một ảnh sai định dạng 2 MB → BỐN lượt POST 2 MB, tất cả đều biết chắc sẽ hỏng;
|
|
80
|
+
* · một **429** của `lib/api/rate-limit.ts` cũng bị thử lại, mỗi lần tiêu thêm một đơn
|
|
81
|
+
* vị hạn mức và bỏ qua `Retry-After` — client tự đào sâu cái hố nó vừa rơi vào.
|
|
82
|
+
* 4xx là "yêu cầu sai, gửi lại cũng thế"; chỉ 5xx mới đáng thử lại.
|
|
83
|
+
*/
|
|
84
|
+
shouldRetry: (xhr) => xhr.status >= 500,
|
|
85
|
+
/*
|
|
86
|
+
* ⚠ `getResponseError` KHÔNG TỒN TẠI — nó chỉ còn trong một dòng comment cũ ở
|
|
87
|
+
* `@uppy/xhr-upload/lib/index.js`, không có trong `XhrUploadOpts`, và không được đọc ở
|
|
88
|
+
* đâu cả. Truyền nó vào là viết một callback không bao giờ chạy. Đường thật là
|
|
89
|
+
* `onAfterResponse`, thứ XHRUpload có forward xuống fetcher.
|
|
90
|
+
*/
|
|
91
|
+
onAfterResponse: (xhr) => {
|
|
92
|
+
if (xhr.status >= 200 && xhr.status < 300) return;
|
|
93
|
+
lastError.status = xhr.status;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(xhr.responseText);
|
|
96
|
+
lastError.code = parsed.error?.code;
|
|
97
|
+
lastError.message = parsed.error?.message;
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
/*
|
|
102
|
+
* ⚠ KHÔNG gửi meta kèm tệp.
|
|
103
|
+
*
|
|
104
|
+
* Mặc định `allowedMetaFields: true` ghi MỌI meta vào cùng `FormData`, và nó ghi TRƯỚC
|
|
105
|
+
* khi append tệp. Route của Comitor đọc `form.get("file")` — hàm đó trả phần tử ĐẦU TIÊN
|
|
106
|
+
* cùng tên. Nên chỉ cần một khoá meta tên `file` là route nhận về một chuỗi,
|
|
107
|
+
* `file instanceof File` thành false, và nó trả 400 "Thiếu tệp ảnh" trong khi tệp vẫn
|
|
108
|
+
* nằm nguyên trong request. Mảng rỗng đóng hẳn đường đó.
|
|
109
|
+
*/
|
|
110
|
+
allowedMetaFields: []
|
|
111
|
+
});
|
|
112
|
+
uppy.on("progress", ((value) => setPercent(value)));
|
|
113
|
+
uppy.on("upload-success", ((file, response) => {
|
|
114
|
+
handlers.current.onUploaded({ body: response?.body, fileName: file?.name ?? "" });
|
|
115
|
+
}));
|
|
116
|
+
uppy.on("upload-error", ((_file, error) => {
|
|
117
|
+
handlers.current.onError?.({
|
|
118
|
+
code: lastError.code,
|
|
119
|
+
status: lastError.status,
|
|
120
|
+
message: lastError.message ?? error?.message ?? text.uploadFailed
|
|
121
|
+
});
|
|
122
|
+
}));
|
|
123
|
+
uppy.on("restriction-failed", ((_file, error) => {
|
|
124
|
+
handlers.current.onError?.({ message: error?.message ?? text.uploadFailed });
|
|
125
|
+
}));
|
|
126
|
+
for (const file of list) uppy.addFile({ name: file.name, type: file.type, data: file });
|
|
127
|
+
await uppy.upload();
|
|
128
|
+
} finally {
|
|
129
|
+
setBusy(false);
|
|
130
|
+
setPercent(0);
|
|
131
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, text.missingDependency, text.uploadFailed]
|
|
135
|
+
);
|
|
136
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2", className), children: [
|
|
137
|
+
/* @__PURE__ */ jsx(
|
|
138
|
+
"input",
|
|
139
|
+
{
|
|
140
|
+
ref: inputRef,
|
|
141
|
+
id: inputId,
|
|
142
|
+
type: "file",
|
|
143
|
+
accept: allowedFileTypes.join(","),
|
|
144
|
+
multiple: maxNumberOfFiles > 1,
|
|
145
|
+
className: "sr-only",
|
|
146
|
+
disabled: disabled || busy,
|
|
147
|
+
onChange: (event) => {
|
|
148
|
+
if (event.target.files) void start(event.target.files);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
),
|
|
152
|
+
/* @__PURE__ */ jsx(
|
|
153
|
+
"div",
|
|
154
|
+
{
|
|
155
|
+
onDragOver: (event) => {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
if (!disabled && !busy) setDragging(true);
|
|
158
|
+
},
|
|
159
|
+
onDragLeave: () => setDragging(false),
|
|
160
|
+
onDrop: (event) => {
|
|
161
|
+
event.preventDefault();
|
|
162
|
+
setDragging(false);
|
|
163
|
+
if (!disabled && !busy && event.dataTransfer.files.length > 0) void start(event.dataTransfer.files);
|
|
164
|
+
},
|
|
165
|
+
className: cn(
|
|
166
|
+
"flex flex-col items-center gap-2 rounded-lg border-2 border-dashed p-4 text-center transition-colors",
|
|
167
|
+
dragging ? "border-primary-ink" : "border-border"
|
|
168
|
+
),
|
|
169
|
+
children: /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-2", (disabled || busy) && "opacity-60"), children: [
|
|
170
|
+
/* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: text.dropHint }),
|
|
171
|
+
/* @__PURE__ */ jsx(Button, { asChild: true, variant: "outline", size: "sm", disabled: disabled || busy, loading: busy, children: /* @__PURE__ */ jsx("label", { htmlFor: inputId, className: disabled || busy ? "pointer-events-none" : "cursor-pointer", children: busy ? text.uploading : text.choose }) })
|
|
172
|
+
] })
|
|
173
|
+
}
|
|
174
|
+
),
|
|
175
|
+
busy ? /* @__PURE__ */ jsx(Progress, { value: percent, "aria-label": text.uploading }) : null
|
|
176
|
+
] });
|
|
177
|
+
}
|
|
178
|
+
export {
|
|
179
|
+
FileUpload
|
|
180
|
+
};
|
|
181
|
+
//# sourceMappingURL=file-upload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/uploader/file-upload.tsx"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { Button } from \"../components/ui/button.js\";\nimport { Progress } from \"../components/ui/progress.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLabels } from \"./file-upload-constants.js\";\n\n/**\n * Ô tải tệp dùng chung cho mọi app Comitor — **Uppy chạy HEADLESS, giao diện là của gói này**.\n *\n * ── VÌ SAO KHÔNG DÙNG `@uppy/dashboard` HAY `@uppy/react` ──────────────────────────────────\n * Cả hai đều kéo theo **Preact** và **một bộ CSS riêng**, tức app đã có React lại ship thêm một\n * runtime giao diện thứ hai, và bảng màu của Uppy không đi qua token của Comitor nên nó KHÔNG qua\n * được cổng tương phản (`pnpm lint:a11y`).\n *\n * Đo được trên một cây đã cài Uppy 5: `@uppy/dashboard` 1.236 KB, `@uppy/react` 432 KB, `preact`\n * 1.852 KB trên đĩa. Trong khi `@uppy/core` khai `sideEffects: [\"*.css\"]` và chỉ có `UIPlugin.js`\n * import `preact` — nên **chỉ nạp `Uppy` + `XHRUpload` thì bundler cắt được cả nhánh Preact**.\n *\n * Cái ta giữ lại của Uppy là phần đáng giá và khó viết đúng: hàng đợi, tiến độ theo byte, thử lại,\n * huỷ, và bộ luật hạn chế (kiểu tệp / kích thước / số lượng) áp NGAY trên trình duyệt.\n *\n * ── VÌ SAO `XHRUpload` CHỨ KHÔNG PHẢI `AwsS3` (presigned PUT) ──────────────────────────────\n * Một app Comitor khác (babrik) tải thẳng lên S3 bằng URL đã ký. Cách đó đúng cho một bucket CÔNG\n * KHAI, nơi URL trả về chính là URL hiển thị. Comitor thì dùng **bucket RIÊNG TƯ**: database lưu\n * KHOÁ object chứ không lưu URL, và URL hiển thị được sinh lại có chữ ký ở mỗi lần đọc.\n *\n * Hệ quả: máy chủ vẫn phải là nơi đặt tên khoá, kiểm byte thật, và ghi khoá vào database. Đẩy\n * việc tải thẳng lên S3 sẽ lấy mất của máy chủ khả năng nhìn thấy nội dung tệp — với một IdP phục\n * vụ ảnh do người dùng tải lên thì đó là đánh đổi không đáng.\n *\n * `XHRUpload` POST một `multipart/form-data` tới chính endpoint của app, nên **không route nào phải\n * đổi** và không có bề mặt bảo mật mới nào mở ra.\n *\n * ── CSP ─────────────────────────────────────────────────────────────────────────────────────\n * Uppy KHÔNG dùng `eval` hay `new Function` (đã grep `@uppy/core`, `@uppy/dashboard`, `@uppy/react`:\n * 0 file). Nên nó chạy được dưới `script-src 'self' 'nonce-…'` mà không cần nới gì.\n */\n\n/** Kết quả một tệp tải xong: thân phản hồi JSON của endpoint app. */\nexport interface FileUploadResult<T = unknown> {\n body: T;\n fileName: string;\n}\n\nexport interface FileUploadProps<T = unknown> {\n /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */\n endpoint: string;\n /** Tên trường trong `FormData`. Mặc định `\"file\"` — khớp khuôn route của Comitor. */\n fieldName?: string;\n /** Giới hạn kích thước mỗi tệp, byte. */\n maxFileSize: number;\n /** MIME được nhận, ví dụ `[\"image/jpeg\", \"image/png\", \"image/webp\"]`. */\n allowedFileTypes: string[];\n /** Số tệp tối đa mỗi lượt. Mặc định 1. */\n maxNumberOfFiles?: number;\n /** Chạy khi MỘT tệp tải xong. Nhận nguyên thân JSON của endpoint. */\n onUploaded: (result: FileUploadResult<T>) => void;\n /**\n * Chạy khi tải hỏng. `code` là `error.code` từ thân phản hồi nếu endpoint trả theo khuôn\n * `{ error: { code } }` của Comitor — nhờ đó chỗ gọi tra được bảng dịch của chính nó.\n */\n onError?: (error: { code?: string; status?: number; message: string }) => void;\n /** Nhãn hiển thị. Mặc định tiếng Việt; truyền `Partial<>` để đổi từng chuỗi. */\n labels?: Partial<FileUploadLabels>;\n disabled?: boolean;\n className?: string;\n}\n\n/**\n * Bề mặt Uppy mà component này dùng — khai TAY, không import kiểu thật.\n *\n * Hai lý do, cả hai đều thực dụng: kiểu thật của `Uppy` mang generic `<Meta, Body>` và đổi hình\n * dạng giữa các bản major; và `@uppy/core` là peer TUỲ CHỌN, nên `import type` từ nó sẽ làm\n * `tsc` của một app KHÔNG cài Uppy báo đỏ ở một file họ không hề dùng.\n *\n * Cái giá: nếu Uppy đổi tên một phương thức thì lỗi lộ ra lúc CHẠY, không lúc biên dịch. Bù lại\n * bằng `scripts/smoke-entries.mjs` — nó nạp thật entry này ở mỗi lần publish.\n */\ninterface UppyLike {\n addFile: (file: { name: string; type: string; data: File }) => void;\n upload: () => Promise<unknown>;\n cancelAll: () => void;\n destroy: () => void;\n on: (event: string, handler: (...args: never[]) => void) => void;\n use: (plugin: unknown, options?: unknown) => unknown;\n}\n\n/**\n * Nạp Uppy LƯỜI — chỉ khi người dùng thật sự chọn tệp.\n *\n * `import()` động giữ Uppy ra khỏi bundle của lượt tải trang đầu tiên. Với một màn hồ sơ mà đa số\n * người mở ra chỉ để xem, đó là toàn bộ chi phí của Uppy bị hoãn tới lúc nó thật sự có việc.\n *\n * ⚠ Hai gói này là **peer TUỲ CHỌN**. App không cài mà lại render component này thì `import()` sẽ\n * hỏng — nên lỗi được bắt và trả về qua `onError` với thông điệp nói đúng tên gói phải cài, thay vì\n * một `ChunkLoadError` không gợi gì.\n */\nasync function loadUppy(): Promise<{ Uppy: new (o: unknown) => UppyLike; XHRUpload: unknown } | null> {\n try {\n const [core, xhr] = await Promise.all([import(\"@uppy/core\"), import(\"@uppy/xhr-upload\")]);\n return {\n // `as unknown as` là bắt buộc: kiểu thật của Uppy có generic `<Meta, Body>` mà `UppyLike`\n // cố ý không mang. Xem ghi chú ở `UppyLike` về việc vì sao gói này không dùng kiểu thật.\n Uppy: (core.default ?? (core as unknown as { Uppy: unknown }).Uppy) as unknown as new (o: unknown) => UppyLike,\n XHRUpload: xhr.default ?? xhr\n };\n } catch {\n return null;\n }\n}\n\nexport function FileUpload<T = unknown>({\n endpoint,\n fieldName = \"file\",\n maxFileSize,\n allowedFileTypes,\n maxNumberOfFiles = 1,\n onUploaded,\n onError,\n labels,\n disabled = false,\n className\n}: FileUploadProps<T>) {\n const text = { ...DEFAULT_FILE_UPLOAD_LABELS, ...labels };\n const inputId = useId();\n const inputRef = useRef<HTMLInputElement>(null);\n const [busy, setBusy] = useState(false);\n const [percent, setPercent] = useState(0);\n const [dragging, setDragging] = useState(false);\n\n /*\n * `onUploaded`/`onError` đi qua ref: chỗ gọi thường truyền hàm mới ở mỗi lần render, và để chúng\n * trong mảng phụ thuộc là dựng lại Uppy sau mỗi phím gõ vào biểu mẫu bên cạnh.\n */\n const handlers = useRef({ onUploaded, onError });\n handlers.current = { onUploaded, onError };\n\n const uppyRef = useRef<UppyLike | null>(null);\n useEffect(() => {\n return () => {\n uppyRef.current?.destroy();\n uppyRef.current = null;\n };\n }, []);\n\n const start = useCallback(\n async (files: FileList | File[]) => {\n const list = Array.from(files).slice(0, maxNumberOfFiles);\n if (list.length === 0) return;\n\n setBusy(true);\n setPercent(0);\n try {\n const mod = await loadUppy();\n if (!mod) {\n handlers.current.onError?.({ message: text.missingDependency });\n return;\n }\n\n /*\n * Dựng MỘT Uppy cho MỘT lượt tải rồi huỷ. Giữ lại một instance dài hạn nghe hấp dẫn hơn,\n * nhưng Uppy nhớ cả những tệp đã hỏng ở lượt trước — chọn lại đúng tệp đó sẽ bị chặn vì\n * \"trùng\", và triệu chứng là một nút bấm không làm gì.\n */\n uppyRef.current?.destroy();\n const uppy = new mod.Uppy({\n autoProceed: false,\n restrictions: { maxNumberOfFiles, maxFileSize, allowedFileTypes }\n });\n uppyRef.current = uppy;\n\n /*\n * Chỗ giữ lỗi của lượt gọi gần nhất. `upload-error` của Uppy KHÔNG mang thân phản hồi —\n * thứ nó ném là `new NetworkError(xhr.statusText, xhr)`, mà trên HTTP/2 `statusText` là\n * chuỗi RỖNG. Nên `code` phải được nhặt ở `onAfterResponse`, nơi còn nhìn thấy `xhr`.\n */\n const lastError: { code?: string; status?: number; message?: string } = {};\n\n uppy.use(mod.XHRUpload, {\n endpoint,\n fieldName,\n method: \"POST\",\n // Cookie phiên là cách Comitor xác thực mọi route tự viết — thiếu dòng này là 401.\n withCredentials: true,\n\n /*\n * ⚠ CHỈ THỬ LẠI LỖI 5xx.\n *\n * Mặc định của Uppy là `shouldRetry = () => true` với `retries = 3` (đọc\n * `@uppy/core/lib/utils/fetcher.js`), tức MỌI phản hồi không-2xx bị gửi lại ba lần. Hậu\n * quả đo được bằng chính hợp đồng của Comitor:\n * · một ảnh sai định dạng 2 MB → BỐN lượt POST 2 MB, tất cả đều biết chắc sẽ hỏng;\n * · một **429** của `lib/api/rate-limit.ts` cũng bị thử lại, mỗi lần tiêu thêm một đơn\n * vị hạn mức và bỏ qua `Retry-After` — client tự đào sâu cái hố nó vừa rơi vào.\n * 4xx là \"yêu cầu sai, gửi lại cũng thế\"; chỉ 5xx mới đáng thử lại.\n */\n shouldRetry: (xhr: XMLHttpRequest) => xhr.status >= 500,\n\n /*\n * ⚠ `getResponseError` KHÔNG TỒN TẠI — nó chỉ còn trong một dòng comment cũ ở\n * `@uppy/xhr-upload/lib/index.js`, không có trong `XhrUploadOpts`, và không được đọc ở\n * đâu cả. Truyền nó vào là viết một callback không bao giờ chạy. Đường thật là\n * `onAfterResponse`, thứ XHRUpload có forward xuống fetcher.\n */\n onAfterResponse: (xhr: XMLHttpRequest) => {\n if (xhr.status >= 200 && xhr.status < 300) return;\n lastError.status = xhr.status;\n try {\n const parsed = JSON.parse(xhr.responseText) as { error?: { code?: string; message?: string } };\n lastError.code = parsed.error?.code;\n lastError.message = parsed.error?.message;\n } catch {\n // Phản hồi không phải JSON (proxy trả HTML, 413 của tầng mạng…). Giữ `status` là đủ.\n }\n },\n\n /*\n * ⚠ KHÔNG gửi meta kèm tệp.\n *\n * Mặc định `allowedMetaFields: true` ghi MỌI meta vào cùng `FormData`, và nó ghi TRƯỚC\n * khi append tệp. Route của Comitor đọc `form.get(\"file\")` — hàm đó trả phần tử ĐẦU TIÊN\n * cùng tên. Nên chỉ cần một khoá meta tên `file` là route nhận về một chuỗi,\n * `file instanceof File` thành false, và nó trả 400 \"Thiếu tệp ảnh\" trong khi tệp vẫn\n * nằm nguyên trong request. Mảng rỗng đóng hẳn đường đó.\n */\n allowedMetaFields: []\n });\n\n uppy.on(\"progress\", ((value: number) => setPercent(value)) as never);\n uppy.on(\"upload-success\", ((file: { name?: string } | undefined, response: { body?: T }) => {\n handlers.current.onUploaded({ body: response?.body as T, fileName: file?.name ?? \"\" });\n }) as never);\n uppy.on(\"upload-error\", ((_file: unknown, error: Error) => {\n // `lastError` là nguồn duy nhất có `code` — xem `onAfterResponse` ở trên.\n handlers.current.onError?.({\n code: lastError.code,\n status: lastError.status,\n message: lastError.message ?? error?.message ?? text.uploadFailed\n });\n }) as never);\n /*\n * `restriction-failed` là đường RIÊNG, không đi qua `upload-error`. Bỏ sót nó thì một tệp\n * quá lớn hay sai định dạng bị Uppy chặn trong im lặng — người dùng bấm rồi không thấy gì.\n */\n uppy.on(\"restriction-failed\", ((_file: unknown, error: Error) => {\n handlers.current.onError?.({ message: error?.message ?? text.uploadFailed });\n }) as never);\n\n for (const file of list) uppy.addFile({ name: file.name, type: file.type, data: file });\n await uppy.upload();\n } finally {\n setBusy(false);\n setPercent(0);\n /*\n * Xoá giá trị input: chọn LẠI cùng một tệp sau một lần lỗi sẽ KHÔNG bắn `change` nếu giá\n * trị không đổi — và triệu chứng là thử lại im lặng không làm gì.\n */\n if (inputRef.current) inputRef.current.value = \"\";\n }\n },\n [endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, text.missingDependency, text.uploadFailed]\n );\n\n return (\n <div className={cn(\"flex flex-col gap-2\", className)}>\n <input\n ref={inputRef}\n id={inputId}\n type=\"file\"\n accept={allowedFileTypes.join(\",\")}\n multiple={maxNumberOfFiles > 1}\n className=\"sr-only\"\n disabled={disabled || busy}\n onChange={(event) => {\n if (event.target.files) void start(event.target.files);\n }}\n />\n\n {/*\n Vùng kéo thả. `<label>` bọc lấy nút nên bấm vào đâu trong vùng cũng mở hộp chọn tệp, và\n bàn phím vẫn tới được qua chính `<input>` (nó `sr-only`, không phải `display:none`).\n */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: vùng kéo-thả là bổ trợ; đường bàn phím đi qua <label> + <input> bên dưới. */}\n <div\n onDragOver={(event) => {\n event.preventDefault();\n if (!disabled && !busy) setDragging(true);\n }}\n onDragLeave={() => setDragging(false)}\n onDrop={(event) => {\n event.preventDefault();\n setDragging(false);\n if (!disabled && !busy && event.dataTransfer.files.length > 0) void start(event.dataTransfer.files);\n }}\n /*\n * ⚠ Trạng thái \"đang kéo tệp qua\" được vẽ bằng VIỀN, không bằng nền mờ.\n *\n * Bản đầu dùng `bg-primary/5` và cổng `pnpm lint:a11y` bác ngay: **1,03:1** ở nền sáng,\n * 1,09:1 ở nền tối — mắt không phân biệt được, tức dấu hiệu kéo-thả là một dấu hiệu GIẢ.\n *\n * ⚠ Và phải là `border-primary-INK`, không phải `border-primary`. Cổng bác cả bản thứ hai:\n * `--primary` là bậc TÔ NỀN của tone, vai trò nét/chữ phải đi qua `--primary-ink` (1,40:1\n * so với 3:1 cần thiết). Cùng luật ba bậc mà CLAUDE.md của Account ghi cho `iconColor`.\n *\n * Giữ `border-2` ở CẢ hai trạng thái để bố cục không nhảy 1px khi kéo tệp qua.\n */\n className={cn(\n \"flex flex-col items-center gap-2 rounded-lg border-2 border-dashed p-4 text-center transition-colors\",\n dragging ? \"border-primary-ink\" : \"border-border\"\n )}\n >\n {/*\n * ⚠ `opacity-60` của trạng thái vô hiệu nằm ở NỘI DUNG, không ở khung.\n *\n * Không phải chuyện thẩm mỹ: cổng `lint:a11y` nhân opacity vào mọi lớp cùng khối, nên để nó\n * ở khung thì `border-primary-ink` rớt còn 2,64:1. Hai trạng thái ấy không bao giờ xảy ra\n * cùng lúc (kéo-thả đã bị chặn khi `disabled || busy`), nhưng cổng đọc TĨNH nên nó ghép —\n * và ở đây cổng đúng về hướng: làm mờ cả khung đứt nét thì người dùng mất luôn dấu hiệu\n * \"chỗ này thả tệp được\".\n */}\n <div className={cn(\"flex flex-col items-center gap-2\", (disabled || busy) && \"opacity-60\")}>\n <p className=\"text-muted-foreground text-sm\">{text.dropHint}</p>\n <Button asChild variant=\"outline\" size=\"sm\" disabled={disabled || busy} loading={busy}>\n <label htmlFor={inputId} className={disabled || busy ? \"pointer-events-none\" : \"cursor-pointer\"}>\n {busy ? text.uploading : text.choose}\n </label>\n </Button>\n </div>\n </div>\n\n {/* Thanh tiến độ chỉ hiện khi đang tải — một thanh 0% đứng yên trông như hỏng. */}\n {busy ? <Progress value={percent} aria-label={text.uploading} /> : null}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/uploader/index.ts"],"sourcesContent":["/**\n * Entry `@comitor/ui/uploader` — ô tải tệp dựng trên Uppy chạy HEADLESS.\n *\n * ── VÌ SAO LÀ MỘT ENTRY RIÊNG ────────────────────────────────────────────────────────────────\n * Cùng lý do `@comitor/ui/chart` tách khỏi entry gốc: `@uppy/core` và `@uppy/xhr-upload` là peer\n * **TUỲ CHỌN**, và `tsup` chạy `bundle: false` — nên một component chạm peer tuỳ chọn mà nằm ở\n * entry `.` sẽ kéo cả cây import đó vào MỌI app dùng gói, kể cả app không tải tệp bao giờ.\n *\n * Ranh giới module là thứ bảo đảm chắc chắn hơn phép tree-shaking: app nào không `import` từ\n * `@comitor/ui/uploader` thì không có một byte Uppy nào trong bundle.\n *\n * ⚠ Uppy còn được nạp LƯỜI lần nữa BÊN TRONG component (`import()` động), nên ngay cả app CÓ dùng\n * cũng chỉ trả giá khi người dùng thật sự chọn tệp — xem `loadUppy()`.\n *\n * Cài ở app:\n * ```bash\n * pnpm add @uppy/core @uppy/xhr-upload\n * ```\n */\n\nexport { FileUpload, type FileUploadProps, type FileUploadResult } from \"./file-upload.js\";\nexport { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLabels } from \"./file-upload-constants.js\";\n"],"mappings":"AAoBA,SAAS,kBAA+D;AACxE,SAAS,kCAAyD;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comitor/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Comitor design system — Shadcn/UI tùy biến theo brand Comitor + component dùng chung cho hệ sinh thái SaaS (Tasks, Chat, CRM, HR…)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/chart/index.d.ts",
|
|
43
43
|
"import": "./dist/chart/index.js"
|
|
44
44
|
},
|
|
45
|
+
"./uploader": {
|
|
46
|
+
"types": "./dist/uploader/index.d.ts",
|
|
47
|
+
"import": "./dist/uploader/index.js"
|
|
48
|
+
},
|
|
45
49
|
"./form": {
|
|
46
50
|
"types": "./dist/form.d.ts",
|
|
47
51
|
"import": "./dist/form.js"
|
|
@@ -53,20 +57,9 @@
|
|
|
53
57
|
"./styles.css": "./styles.css",
|
|
54
58
|
"./package.json": "./package.json"
|
|
55
59
|
},
|
|
56
|
-
"scripts": {
|
|
57
|
-
"build": "tsup",
|
|
58
|
-
"typecheck": "tsc --noEmit",
|
|
59
|
-
"lint": "biome check .",
|
|
60
|
-
"lint:fix": "biome check --write .",
|
|
61
|
-
"lint:a11y": "node scripts/check-contrast.mjs",
|
|
62
|
-
"test:a11y-gate": "node scripts/a11y/self-test.mjs",
|
|
63
|
-
"test:nav-active": "node scripts/check-nav-active.mjs",
|
|
64
|
-
"verify:directives": "node scripts/verify-directives.mjs",
|
|
65
|
-
"smoke:entries": "node scripts/smoke-entries.mjs",
|
|
66
|
-
"check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
|
|
67
|
-
"prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run smoke:entries && pnpm run check:exports && pnpm run test:nav-active && pnpm run test:a11y-gate && pnpm run lint:a11y"
|
|
68
|
-
},
|
|
69
60
|
"peerDependencies": {
|
|
61
|
+
"@uppy/core": "^6.0.0",
|
|
62
|
+
"@uppy/xhr-upload": "^6.0.0",
|
|
70
63
|
"lucide-react": ">=0.540.0",
|
|
71
64
|
"next": ">=15",
|
|
72
65
|
"next-themes": ">=0.4",
|
|
@@ -77,6 +70,12 @@
|
|
|
77
70
|
"tailwindcss": "^4.1.0"
|
|
78
71
|
},
|
|
79
72
|
"peerDependenciesMeta": {
|
|
73
|
+
"@uppy/core": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@uppy/xhr-upload": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
80
79
|
"next": {
|
|
81
80
|
"optional": true
|
|
82
81
|
},
|
|
@@ -136,6 +135,8 @@
|
|
|
136
135
|
"@types/node": "^22.0.0",
|
|
137
136
|
"@types/react": "^19.0.0",
|
|
138
137
|
"@types/react-dom": "^19.0.0",
|
|
138
|
+
"@uppy/core": "^6.0.0",
|
|
139
|
+
"@uppy/xhr-upload": "^6.0.0",
|
|
139
140
|
"lucide-react": "^1.33.0",
|
|
140
141
|
"next": "^16.0.0",
|
|
141
142
|
"next-themes": "^0.4.6",
|
|
@@ -147,5 +148,17 @@
|
|
|
147
148
|
"tailwindcss": "^4.2.0",
|
|
148
149
|
"tsup": "^8.3.5",
|
|
149
150
|
"typescript": "^5.7.0"
|
|
151
|
+
},
|
|
152
|
+
"scripts": {
|
|
153
|
+
"build": "tsup",
|
|
154
|
+
"typecheck": "tsc --noEmit",
|
|
155
|
+
"lint": "biome check .",
|
|
156
|
+
"lint:fix": "biome check --write .",
|
|
157
|
+
"lint:a11y": "node scripts/check-contrast.mjs",
|
|
158
|
+
"test:a11y-gate": "node scripts/a11y/self-test.mjs",
|
|
159
|
+
"test:nav-active": "node scripts/check-nav-active.mjs",
|
|
160
|
+
"verify:directives": "node scripts/verify-directives.mjs",
|
|
161
|
+
"smoke:entries": "node scripts/smoke-entries.mjs",
|
|
162
|
+
"check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css"
|
|
150
163
|
}
|
|
151
|
-
}
|
|
164
|
+
}
|