@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.57

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.
Files changed (250) hide show
  1. package/.cursor/mcp.json +8 -0
  2. package/.mcp.json +8 -0
  3. package/.prettierignore +20 -0
  4. package/.vscode/mcp.json +8 -0
  5. package/AGENTS.md +432 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +481 -0
  10. package/cli/plugins.ts +14 -0
  11. package/client/agent-annotations-host.ts +19 -0
  12. package/client/app.ts +38 -0
  13. package/client/components/back-button.tsx +49 -0
  14. package/client/components/breadcrumbs.tsx +167 -0
  15. package/client/components/inbox-badge.ts +49 -0
  16. package/client/components/inbox-button.tsx +132 -0
  17. package/client/components/inbox-header-button.tsx +25 -0
  18. package/client/components/loading.tsx +35 -0
  19. package/client/components/page-container.tsx +24 -0
  20. package/client/components/page-header.tsx +33 -0
  21. package/client/components/route-child-page.tsx +121 -0
  22. package/client/components/route-dialog.tsx +10 -0
  23. package/client/components/route-drawer.tsx +10 -0
  24. package/client/components/route-overlay.tsx +190 -0
  25. package/client/components/ui/alert.tsx +75 -0
  26. package/client/components/ui/badge.tsx +51 -0
  27. package/client/components/ui/breadcrumb.tsx +121 -0
  28. package/client/components/ui/button.tsx +58 -0
  29. package/client/components/ui/collapsible.tsx +19 -0
  30. package/client/components/ui/context-menu.tsx +271 -0
  31. package/client/components/ui/dialog.tsx +160 -0
  32. package/client/components/ui/dropdown-menu.tsx +270 -0
  33. package/client/components/ui/empty.tsx +103 -0
  34. package/client/components/ui/field.tsx +236 -0
  35. package/client/components/ui/input-group.tsx +158 -0
  36. package/client/components/ui/input.tsx +20 -0
  37. package/client/components/ui/kbd.tsx +26 -0
  38. package/client/components/ui/label.tsx +18 -0
  39. package/client/components/ui/popover.tsx +89 -0
  40. package/client/components/ui/select.tsx +200 -0
  41. package/client/components/ui/separator.tsx +22 -0
  42. package/client/components/ui/sheet.tsx +137 -0
  43. package/client/components/ui/sidebar.tsx +723 -0
  44. package/client/components/ui/skeleton.tsx +13 -0
  45. package/client/components/ui/spinner.tsx +20 -0
  46. package/client/components/ui/tabs.tsx +79 -0
  47. package/client/components/ui/textarea.tsx +17 -0
  48. package/client/components/ui/toast.tsx +231 -0
  49. package/client/components/ui/tooltip.tsx +63 -0
  50. package/client/components/use-route-overlay.ts +26 -0
  51. package/client/config/api.ts +13 -0
  52. package/client/config/app.ts +8 -0
  53. package/client/config/auth.ts +18 -0
  54. package/client/config/index.ts +17 -0
  55. package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
  56. package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
  57. package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
  58. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
  59. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
  60. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
  61. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
  62. package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
  63. package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
  64. package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
  65. package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
  66. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
  67. package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
  68. package/client/extensions/nocobase-file-component-ui/README.md +13 -0
  69. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
  70. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
  71. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
  72. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
  73. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
  74. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
  75. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
  76. package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
  77. package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
  78. package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
  79. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
  80. package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
  81. package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
  82. package/client/extensions/nocobase-inbox/frame.ts +31 -0
  83. package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
  84. package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
  85. package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
  86. package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
  87. package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
  88. package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
  89. package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
  90. package/client/extensions/nocobase-inbox/model.ts +428 -0
  91. package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
  92. package/client/extensions/nocobase-inbox/registry.ts +303 -0
  93. package/client/extensions/nocobase-inbox/source.ts +117 -0
  94. package/client/hooks/use-mobile.ts +21 -0
  95. package/client/index.tsx +60 -0
  96. package/client/layouts/app-layout.tsx +105 -0
  97. package/client/layouts/components/app-brand.tsx +49 -0
  98. package/client/layouts/components/app-sidebar.tsx +196 -0
  99. package/client/layouts/components/header-actions.tsx +50 -0
  100. package/client/layouts/components/language-switcher.tsx +95 -0
  101. package/client/layouts/components/layout-header.tsx +17 -0
  102. package/client/layouts/components/navigation-menu.tsx +411 -0
  103. package/client/layouts/components/sidebar-footer.tsx +107 -0
  104. package/client/layouts/components/surface-empty.tsx +35 -0
  105. package/client/layouts/components/user-menu.tsx +127 -0
  106. package/client/layouts/index.ts +2 -0
  107. package/client/layouts/settings-layout.tsx +172 -0
  108. package/client/layouts/use-sidebar-preference.ts +57 -0
  109. package/client/lib/constants.ts +5 -0
  110. package/client/lib/toaster.ts +56 -0
  111. package/client/lib/utils.ts +1 -0
  112. package/client/locales/en-US.ts +178 -0
  113. package/client/locales/index.ts +9 -0
  114. package/client/locales/zh-CN.ts +161 -0
  115. package/client/pages/auth/device.tsx +41 -0
  116. package/client/pages/auth/forgot-password.tsx +39 -0
  117. package/client/pages/auth/login.tsx +66 -0
  118. package/client/pages/auth/register.tsx +46 -0
  119. package/client/pages/auth/reset-password.tsx +38 -0
  120. package/client/pages/auth/shared.tsx +111 -0
  121. package/client/pages/home.tsx +16 -0
  122. package/client/pages/inbox.tsx +12 -0
  123. package/client/plugins.ts +41 -0
  124. package/client/react-providers.ts +27 -0
  125. package/client/route-overrides.ts +9 -0
  126. package/client/routes.ts +75 -0
  127. package/client/routing/app-router.tsx +127 -0
  128. package/client/routing/client-route.tsx +129 -0
  129. package/client/routing/route-context.tsx +85 -0
  130. package/client/routing/route-navigation.ts +153 -0
  131. package/client/routing/route-tree.tsx +42 -0
  132. package/client/routing/standalone-page-layout.tsx +15 -0
  133. package/client/runtime.ts +26 -0
  134. package/client/service-provider.ts +40 -0
  135. package/client/source-extensions.ts +19 -0
  136. package/client/startup.tsx +22 -0
  137. package/client/styles.css +122 -0
  138. package/client/theme/index.ts +4 -0
  139. package/client/theme/theme-context.ts +12 -0
  140. package/client/theme/theme-preferences.ts +84 -0
  141. package/client/theme/theme-presets.ts +7 -0
  142. package/client/theme/theme-provider.tsx +106 -0
  143. package/client/theme/theme-settings.tsx +176 -0
  144. package/client/theme/themes/compact.css +140 -0
  145. package/client/theme/themes/default.css +117 -0
  146. package/client/vite-env.d.ts +5 -0
  147. package/components.json +27 -0
  148. package/config.example.yml +172 -0
  149. package/database/main/migrations/.gitkeep +0 -0
  150. package/database/main/migrations/202610060001_create_device_code.ts +37 -0
  151. package/database/main/seeds/.gitkeep +0 -0
  152. package/database/tsconfig.json +4 -0
  153. package/ecosystem.config.js +35 -0
  154. package/eslint.config.js +24 -0
  155. package/index.html +100 -0
  156. package/package.json +200 -3
  157. package/playwright.config.ts +10 -0
  158. package/public/assets/favicon.ico +0 -0
  159. package/public/assets/logo-dark.png +0 -0
  160. package/public/assets/logo-mark-dark.png +0 -0
  161. package/public/assets/logo-mark.png +0 -0
  162. package/public/assets/logo.png +0 -0
  163. package/server/app.ts +50 -0
  164. package/server/config/ai.ts +27 -0
  165. package/server/config/api.ts +8 -0
  166. package/server/config/app.ts +40 -0
  167. package/server/config/auth.ts +36 -0
  168. package/server/config/authorization.ts +18 -0
  169. package/server/config/caching.ts +21 -0
  170. package/server/config/database.ts +22 -0
  171. package/server/config/drive.ts +30 -0
  172. package/server/config/i18n.ts +17 -0
  173. package/server/config/index.ts +73 -0
  174. package/server/config/jobs.ts +27 -0
  175. package/server/config/logging.ts +23 -0
  176. package/server/config/notification.ts +11 -0
  177. package/server/config/queue.ts +28 -0
  178. package/server/config/scheduler.ts +23 -0
  179. package/server/config/secrets.ts +10 -0
  180. package/server/config/server.ts +26 -0
  181. package/server/config/session.ts +60 -0
  182. package/server/config/snowflake.ts +21 -0
  183. package/server/config/spa.ts +24 -0
  184. package/server/config/users.ts +11 -0
  185. package/server/config/workflow.ts +24 -0
  186. package/server/config.ts +24 -0
  187. package/server/embedded.ts +23 -0
  188. package/server/locales/en-US.ts +10 -0
  189. package/server/locales/index.ts +10 -0
  190. package/server/locales/zh-CN.ts +5 -0
  191. package/server/plugins.ts +41 -0
  192. package/server/providers/index.ts +5 -0
  193. package/server/routes/index.ts +6 -0
  194. package/server/runtime.ts +24 -0
  195. package/server/standalone.ts +29 -0
  196. package/server/tsconfig.json +4 -0
  197. package/tailwind.config.mjs +59 -0
  198. package/tests/components/app-layout-routes.test.tsx +132 -0
  199. package/tests/components/app-title.test.tsx +57 -0
  200. package/tests/components/auth-i18n.test.tsx +87 -0
  201. package/tests/components/back-button.test.tsx +80 -0
  202. package/tests/components/file-preview.test.tsx +101 -0
  203. package/tests/components/header-hover.test.tsx +87 -0
  204. package/tests/components/inbox.test.tsx +139 -0
  205. package/tests/components/language-switcher.test.tsx +298 -0
  206. package/tests/components/layout-containers.test.tsx +15 -0
  207. package/tests/components/layout-i18n.test.tsx +187 -0
  208. package/tests/components/loading.test.tsx +25 -0
  209. package/tests/components/navigation-menu.test.tsx +355 -0
  210. package/tests/components/page-harness.test.tsx +318 -0
  211. package/tests/components/primitive-labels.test.tsx +80 -0
  212. package/tests/components/sidebar-permissions.test.tsx +110 -0
  213. package/tests/components/sidebar-preference.test.tsx +42 -0
  214. package/tests/fixtures/theme-tokens.html +12 -0
  215. package/tests/fixtures/theme-tokens.tsx +111 -0
  216. package/tests/logic/account-permissions.test.tsx +204 -0
  217. package/tests/logic/app-locale-coverage.test.ts +136 -0
  218. package/tests/logic/app-server.test.ts +1589 -0
  219. package/tests/logic/asset-url-build.test.ts +106 -0
  220. package/tests/logic/client-auth.test.tsx +122 -0
  221. package/tests/logic/client-plugin-registry.test.ts +63 -0
  222. package/tests/logic/client-route-recovery.test.tsx +80 -0
  223. package/tests/logic/client-routes.test.ts +126 -0
  224. package/tests/logic/client-runtime.test.ts +112 -0
  225. package/tests/logic/client-settings.test.tsx +841 -0
  226. package/tests/logic/client-shell.test.tsx +466 -0
  227. package/tests/logic/client-theme.test.tsx +441 -0
  228. package/tests/logic/config.test.ts +197 -0
  229. package/tests/logic/dev-client-watch.test.ts +108 -0
  230. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  231. package/tests/logic/device-code-migration.test.ts +70 -0
  232. package/tests/logic/lifecycle.test.ts +221 -0
  233. package/tests/logic/plugin-commands.test.ts +69 -0
  234. package/tests/logic/plugin-page-authz.test.ts +76 -0
  235. package/tests/logic/route-navigation.test.ts +66 -0
  236. package/tests/logic/route-overlay.test.tsx +353 -0
  237. package/tests/logic/server-imports.test.ts +88 -0
  238. package/tests/logic/tailwind-sources.test.ts +151 -0
  239. package/tests/logic/template-package-name.test.ts +88 -0
  240. package/tests/logic/theme-preferences.test.ts +86 -0
  241. package/tests/logic/theme-tokens.test.ts +248 -0
  242. package/tests/logic/toaster.test.tsx +108 -0
  243. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  244. package/tests/setup/client-config.ts +27 -0
  245. package/tsconfig.json +11 -0
  246. package/tsconfig.node.json +12 -0
  247. package/tsconfig.server.json +19 -0
  248. package/vite.config.ts +92 -0
  249. package/vitest.config.ts +37 -0
  250. package/README.md +0 -3
@@ -0,0 +1,176 @@
1
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+
4
+ /**
5
+ * Where an approval stands: `checking` the code, `ready` to decide, `approving` or `denying`, decided (`approved`,
6
+ * `denied`), or a code that cannot be decided here: `expired`, `invalid`, `notYours` (another account claimed it), or
7
+ * an `error` worth retrying. `idle` means there is no code yet.
8
+ */
9
+ export type DeviceApprovalStatus =
10
+ | 'idle'
11
+ | 'checking'
12
+ | 'ready'
13
+ | 'approving'
14
+ | 'denying'
15
+ | 'approved'
16
+ | 'denied'
17
+ | 'expired'
18
+ | 'invalid'
19
+ | 'notYours'
20
+ | 'error';
21
+
22
+ export interface DeviceApprovalState {
23
+ readonly status: DeviceApprovalStatus;
24
+ /** The client that asked, such as `acme`, once the code is claimed for the signed-in person. */
25
+ readonly clientId?: string;
26
+ readonly approve: () => Promise<void>;
27
+ readonly deny: () => Promise<void>;
28
+ /** Checks the code again, after an `error`. */
29
+ readonly retry: () => void;
30
+ }
31
+
32
+ interface Verification {
33
+ readonly status?: string;
34
+ readonly client_id?: string;
35
+ }
36
+
37
+ interface FetchResult {
38
+ readonly data: unknown;
39
+ readonly error: unknown;
40
+ }
41
+
42
+ /** `abcd2345` as `ABCD-2345`: upper case, and an eight-character code in two groups of four. */
43
+ export function formatUserCode(userCode: string): string {
44
+ const normalized = userCode.replace(/[^a-z0-9]/giu, '').toUpperCase();
45
+ return normalized.length === 8
46
+ ? `${normalized.slice(0, 4)}-${normalized.slice(4)}`
47
+ : normalized;
48
+ }
49
+
50
+ /** The status a failed device request means, read from Better Auth's `{ error, error_description }` body. */
51
+ export function deviceErrorStatus(error: unknown): DeviceApprovalStatus {
52
+ const body =
53
+ typeof error === 'object' && error !== null
54
+ ? (error as Record<string, unknown>)
55
+ : {};
56
+ const code =
57
+ typeof body.error === 'string'
58
+ ? body.error
59
+ : typeof body.code === 'string'
60
+ ? body.code
61
+ : '';
62
+ switch (code.toLowerCase()) {
63
+ case 'expired_token':
64
+ return 'expired';
65
+ case 'invalid_request':
66
+ case 'invalid_user_code':
67
+ return 'invalid';
68
+ case 'access_denied':
69
+ return 'notYours';
70
+ default:
71
+ return 'error';
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Drives one device authorization through Better Auth's `deviceAuthorization()` endpoints with the signed-in person's
77
+ * session: `GET /device` checks the code and claims it for this person, which Better Auth requires before the code can
78
+ * be approved or denied, then `POST /device/approve` or `/device/deny` decides it. The check runs once per code, since
79
+ * Better Auth rate-limits it, and never polls.
80
+ */
81
+ export function useDeviceApproval(
82
+ userCode: string | undefined,
83
+ ): DeviceApprovalState {
84
+ const { client } = useAuthentication();
85
+ const code = userCode?.trim() ?? '';
86
+ const [attempt, setAttempt] = useState(0);
87
+ // Which check the state answers: a new code or a retry is being checked until its answer arrives.
88
+ const key = `${code}#${attempt}`;
89
+ const [state, setState] = useState<{
90
+ key: string;
91
+ status: DeviceApprovalStatus;
92
+ clientId?: string;
93
+ }>({ key: '', status: 'idle' });
94
+ const checkedRef = useRef<string | null>(null);
95
+
96
+ const request = useCallback(
97
+ (path: string, init: Record<string, unknown>): Promise<FetchResult> =>
98
+ client.$fetch(path, init),
99
+ [client],
100
+ );
101
+
102
+ useEffect(() => {
103
+ // Once per code: StrictMode runs effects twice, and the endpoint counts every call against its rate limit.
104
+ if (code === '' || checkedRef.current === key) return;
105
+ checkedRef.current = key;
106
+ void request('/device', {
107
+ method: 'GET',
108
+ query: { user_code: code },
109
+ }).then(
110
+ ({ data, error }) => {
111
+ if (checkedRef.current !== key) return;
112
+ if (error) {
113
+ setState({ key, status: deviceErrorStatus(error) });
114
+ return;
115
+ }
116
+ const verification = (data ?? {}) as Verification;
117
+ // Better Auth names the client only to the person the code is claimed for.
118
+ if (verification.client_id === undefined) {
119
+ setState({
120
+ key,
121
+ status: verification.status === 'pending' ? 'notYours' : 'invalid',
122
+ });
123
+ return;
124
+ }
125
+ setState({
126
+ key,
127
+ clientId: verification.client_id,
128
+ status:
129
+ verification.status === 'approved'
130
+ ? 'approved'
131
+ : verification.status === 'denied'
132
+ ? 'denied'
133
+ : 'ready',
134
+ });
135
+ },
136
+ () => {
137
+ if (checkedRef.current === key) setState({ key, status: 'error' });
138
+ },
139
+ );
140
+ }, [code, key, request]);
141
+
142
+ const decide = useCallback(
143
+ async (decision: 'approve' | 'deny'): Promise<void> => {
144
+ setState((current) => ({
145
+ ...current,
146
+ status: decision === 'approve' ? 'approving' : 'denying',
147
+ }));
148
+ try {
149
+ const { error } = await request(`/device/${decision}`, {
150
+ method: 'POST',
151
+ body: { userCode: code },
152
+ });
153
+ setState((current) => ({
154
+ ...current,
155
+ status: error
156
+ ? deviceErrorStatus(error)
157
+ : decision === 'approve'
158
+ ? 'approved'
159
+ : 'denied',
160
+ }));
161
+ } catch {
162
+ setState((current) => ({ ...current, status: 'error' }));
163
+ }
164
+ },
165
+ [code, request],
166
+ );
167
+
168
+ const current = state.key === key ? state : undefined;
169
+ return {
170
+ status: code === '' ? 'idle' : (current?.status ?? 'checking'),
171
+ ...(current?.clientId === undefined ? {} : { clientId: current.clientId }),
172
+ approve: () => decide('approve'),
173
+ deny: () => decide('deny'),
174
+ retry: () => setAttempt((value) => value + 1),
175
+ };
176
+ }
@@ -0,0 +1,13 @@
1
+ # File Component UI
2
+
3
+ This Registry item installs editable App source for upload, list, thumbnail, and preview controls. The default destination is client/extensions/nocobase-file-component-ui. Import components and UI types from its index.ts.
4
+
5
+ Pass the public ClientFileRepository returned by the App's clientFileRepositoryManagerToken to FileUploadField as repository. Read-only components consume FileRecord arrays and their contentUrl directly; they require no repository prop. See the Registry components section in the plugin's SKILL.md for installation, dependencies, and a complete React example.
6
+
7
+ Upload success is controlled by value/onChange. Temporary progress and error items stay internal. Use onStatusChange to block form submission while uploading or after a failure. Upload cancellation forwards AbortSignal; it does not guarantee rollback after a server commit. removeOnDelete removes metadata only. App-owned business relations and physical cleanup remain the App's responsibility.
8
+
9
+ Preview handles safe raster images, PDF, audio/video and text/Markdown. The content route serves attachments, so PDF is fetched and embedded as a local blob retyped to `application/pdf`; a response carrying an active markup type is reported as a preview failure rather than embedded, because a blob URL is same-origin. DOCX/XLSX/PPTX render locally with lazily loaded `@silurus/ooxml`; legacy Office and OpenDocument formats retain Office Online, which requires an internet-accessible absolute URL and cannot use the App session. Active HTML/SVG/XML and unsafe URL schemes are rejected. Markdown does not execute raw HTML.
10
+
11
+ Follow the plugin Skill's Registry installation and preview sections for the exact viewer dependency, Vite dependency-prebundling exclusion, authentication adapter boundaries and acceptance checks. Same-origin content fetches use session cookies; external fetches omit credentials and require CORS. Bearer-only access requires changes to the App-owned source; setting `contentUrl` to an arbitrary blob URL is not supported. OOXML failures show an error and an optional download action; `download={false}` suppresses that action.
12
+
13
+ There is no public/private record flag or access-token endpoint. The item contains no extension.ts, route, collection, or Server implementation. A private disk does not authorize a content request. Installed copies belong to the App; review upgrades with a three-way merge and preserve customizations. Supply translated labels and adapt additional copy in installed source.
@@ -0,0 +1,147 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { Download, Eye, Trash2 } from 'lucide-react';
3
+ import { useState, type ReactElement } from 'react';
4
+
5
+ import type { FileListProps, FileRecord } from '../types';
6
+ import { Button } from '#components/ui/button';
7
+ import { resolveSafeFileUrl } from '../lib/file-url';
8
+ import { FilePreviewDialog } from './file-preview-dialog';
9
+ import { FileThumbnail } from './file-thumbnail';
10
+
11
+ function triggerDownload(url: string, filename: string): void {
12
+ const link = document.createElement('a');
13
+ link.href = url;
14
+ link.download = filename;
15
+ link.rel = 'noopener';
16
+ link.click();
17
+ }
18
+
19
+ export function FileList(inputProps: FileListProps): ReactElement {
20
+ const { t } = useTranslation('@nocobase/app-plugin-file');
21
+ const {
22
+ files,
23
+ onPreview,
24
+ onDownload,
25
+ onRemove,
26
+ onError,
27
+ labels,
28
+ emptyState,
29
+ } = inputProps;
30
+
31
+ const [previewIndex, setPreviewIndex] = useState(0);
32
+ const [previewOpen, setPreviewOpen] = useState(false);
33
+ const previewLabel =
34
+ labels?.preview ?? t('files.preview', { defaultValue: 'Preview' });
35
+ const downloadLabel =
36
+ labels?.download ?? t('files.download', { defaultValue: 'Download' });
37
+ const removeLabel =
38
+ labels?.remove ?? t('files.remove', { defaultValue: 'Remove' });
39
+
40
+ if (!files.length)
41
+ return (
42
+ <div role='status'>
43
+ {emptyState ??
44
+ labels?.empty ??
45
+ t('files.empty', { defaultValue: 'No files.' })}
46
+ </div>
47
+ );
48
+
49
+ const downloadFile = (file: FileRecord): void => {
50
+ if (onDownload) {
51
+ onDownload(file);
52
+ return;
53
+ }
54
+ void (async () => {
55
+ const raw = file.contentUrl;
56
+ const url = raw ? resolveSafeFileUrl(raw) : undefined;
57
+ if (!url)
58
+ throw new Error(
59
+ t('urlNotAllowed', {
60
+ defaultValue: 'File URL is not allowed.',
61
+ }),
62
+ );
63
+ triggerDownload(url, file.filename);
64
+ })().catch((error: unknown) => {
65
+ onError?.(
66
+ error instanceof Error
67
+ ? error
68
+ : new Error(
69
+ t('downloadFailed', {
70
+ defaultValue: 'File download failed.',
71
+ }),
72
+ ),
73
+ );
74
+ });
75
+ };
76
+
77
+ return (
78
+ <>
79
+ <ul data-slot='file-list' className='grid gap-3 sm:grid-cols-2'>
80
+ {files.map((file, index) => (
81
+ <li
82
+ key={file.id}
83
+ className='flex min-w-0 items-center gap-3 rounded-md border p-3'
84
+ >
85
+ <div className='h-12 w-12 shrink-0 overflow-hidden rounded-md'>
86
+ <FileThumbnail file={file} />
87
+ </div>
88
+ <div className='min-w-0 flex-1'>
89
+ <div className='truncate font-medium' title={file.filename}>
90
+ {file.filename}
91
+ </div>
92
+ <div className='text-sm text-muted-foreground'>
93
+ {file.mimeType}
94
+ </div>
95
+ </div>
96
+ <div className='flex shrink-0 items-center gap-1'>
97
+ <Button
98
+ type='button'
99
+ size='icon'
100
+ variant='ghost'
101
+ aria-label={`${previewLabel}: ${file.filename}`}
102
+ title={previewLabel}
103
+ onClick={() => {
104
+ onPreview?.(file);
105
+ setPreviewIndex(index);
106
+ setPreviewOpen(true);
107
+ }}
108
+ >
109
+ <Eye aria-hidden='true' />
110
+ </Button>
111
+ <Button
112
+ type='button'
113
+ size='icon'
114
+ variant='ghost'
115
+ aria-label={`${downloadLabel}: ${file.filename}`}
116
+ title={downloadLabel}
117
+ onClick={() => downloadFile(file)}
118
+ >
119
+ <Download aria-hidden='true' />
120
+ </Button>
121
+ {onRemove ? (
122
+ <Button
123
+ type='button'
124
+ size='icon'
125
+ variant='ghost'
126
+ aria-label={`${removeLabel}: ${file.filename}`}
127
+ title={removeLabel}
128
+ onClick={() => void onRemove(file)}
129
+ >
130
+ <Trash2 aria-hidden='true' />
131
+ </Button>
132
+ ) : null}
133
+ </div>
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ <FilePreviewDialog
138
+ files={files}
139
+ initialIndex={previewIndex}
140
+ open={previewOpen}
141
+ onOpenChange={setPreviewOpen}
142
+ labels={labels}
143
+ onError={onError}
144
+ />
145
+ </>
146
+ );
147
+ }
@@ -0,0 +1,263 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { ChevronLeft, ChevronRight, Download } from 'lucide-react';
3
+ import { useEffect, useMemo, useState, type ReactElement } from 'react';
4
+
5
+ import type {
6
+ FilePreviewDialogProps,
7
+ FileRecord,
8
+ FileUiLabels,
9
+ } from '../types';
10
+ import {
11
+ isActiveMarkupMimeType,
12
+ resolveFilePreviewKind,
13
+ type FilePreviewKind,
14
+ } from '../lib/file-preview';
15
+ import { Button } from '#components/ui/button';
16
+ import { Dialog, DialogContent, DialogTitle } from '#components/ui/dialog';
17
+ import { fileUrlCredentials, resolveSafeFileUrl } from '../lib/file-url';
18
+ import { FilePreviewContent } from './previewers/file-preview-content';
19
+
20
+ const PDF_MIME_TYPE = 'application/pdf';
21
+ const PDF_PREVIEW_ERROR = 'Unable to load the PDF preview.';
22
+ const PDF_MARKUP_RESPONSE_ERROR =
23
+ 'The file URL returned HTML or XML instead of a PDF.';
24
+
25
+ export function FilePreviewDialog({
26
+ files,
27
+ initialIndex = 0,
28
+ open,
29
+ onOpenChange,
30
+ download: allowDownload = true,
31
+ labels,
32
+ onError,
33
+ }: FilePreviewDialogProps): ReactElement | null {
34
+ if (!open || !files.length) return null;
35
+ const normalizedIndex = Math.max(0, Math.min(initialIndex, files.length - 1));
36
+ return (
37
+ <OpenFilePreviewDialog
38
+ key={`${normalizedIndex}:${files.map((file) => file.id).join(':')}`}
39
+ files={files}
40
+ initialIndex={normalizedIndex}
41
+ onOpenChange={onOpenChange}
42
+ download={allowDownload}
43
+ labels={labels}
44
+ onError={onError}
45
+ />
46
+ );
47
+ }
48
+
49
+ interface OpenFilePreviewDialogProps {
50
+ readonly files: readonly FileRecord[];
51
+ readonly initialIndex: number;
52
+ readonly onOpenChange: (open: boolean) => void;
53
+ readonly download: boolean;
54
+ readonly labels?: FileUiLabels;
55
+ readonly onError?: (error: Error) => void;
56
+ }
57
+
58
+ function OpenFilePreviewDialog(
59
+ inputProps: OpenFilePreviewDialogProps,
60
+ ): ReactElement {
61
+ const { t } = useTranslation('@nocobase/app-plugin-file');
62
+ const {
63
+ files,
64
+ initialIndex,
65
+ onOpenChange,
66
+ download: allowDownload,
67
+ labels,
68
+ onError,
69
+ } = inputProps;
70
+
71
+ const [index, setIndex] = useState(initialIndex);
72
+ const file = files[index];
73
+ if (!file) throw new Error('A preview file is required.');
74
+ const downloadLabel =
75
+ labels?.download ?? t('files.download', { defaultValue: 'Download' });
76
+ return (
77
+ <Dialog open onOpenChange={onOpenChange}>
78
+ <DialogContent
79
+ className='flex max-h-[calc(100dvh-2rem)] max-w-[calc(100%-2rem)] flex-col sm:max-w-4xl'
80
+ showCloseButton
81
+ >
82
+ <div className='flex items-center justify-between gap-3 pr-10'>
83
+ <div className='min-w-0'>
84
+ <DialogTitle className='truncate'>{file.filename}</DialogTitle>
85
+ <p className='text-sm text-muted-foreground'>{file.mimeType}</p>
86
+ </div>
87
+ <div className='flex gap-1'>
88
+ {files.length > 1 ? (
89
+ <>
90
+ <Button
91
+ type='button'
92
+ size='icon'
93
+ variant='ghost'
94
+ aria-label={t('files.previous', {
95
+ defaultValue: 'Previous file',
96
+ })}
97
+ onClick={() =>
98
+ setIndex(
99
+ (value) => (value - 1 + files.length) % files.length,
100
+ )
101
+ }
102
+ >
103
+ <ChevronLeft aria-hidden='true' />
104
+ </Button>
105
+ <Button
106
+ type='button'
107
+ size='icon'
108
+ variant='ghost'
109
+ aria-label={t('files.next', { defaultValue: 'Next file' })}
110
+ onClick={() =>
111
+ setIndex((value) => (value + 1) % files.length)
112
+ }
113
+ >
114
+ <ChevronRight aria-hidden='true' />
115
+ </Button>
116
+ </>
117
+ ) : null}
118
+ {allowDownload ? (
119
+ <Button
120
+ type='button'
121
+ size='icon'
122
+ variant='ghost'
123
+ aria-label={`${downloadLabel}: ${file.filename}`}
124
+ onClick={() =>
125
+ void downloadFile(file).catch((error: unknown) =>
126
+ reportDownloadError(onError, error),
127
+ )
128
+ }
129
+ >
130
+ <Download aria-hidden='true' />
131
+ </Button>
132
+ ) : null}
133
+ </div>
134
+ </div>
135
+ {/* Only the preview scrolls; the title and its actions stay in view. */}
136
+ <div className='-mx-4 min-h-0 flex-1 overflow-auto px-4'>
137
+ <PreviewBody
138
+ key={`${file.id}:${String(file.updatedAt)}:${file.contentUrl}`}
139
+ file={file}
140
+ onDownload={
141
+ allowDownload
142
+ ? () =>
143
+ void downloadFile(file).catch((error: unknown) =>
144
+ reportDownloadError(onError, error),
145
+ )
146
+ : undefined
147
+ }
148
+ />
149
+ </div>
150
+ </DialogContent>
151
+ </Dialog>
152
+ );
153
+ }
154
+
155
+ async function downloadFile(file: FileRecord): Promise<void> {
156
+ const raw = file.contentUrl;
157
+ const url = raw ? resolveSafeFileUrl(raw) : undefined;
158
+ if (!url) throw new Error('File URL is not allowed.');
159
+ const link = document.createElement('a');
160
+ link.href = url;
161
+ link.download = file.filename;
162
+ link.rel = 'noopener';
163
+ link.click();
164
+ }
165
+
166
+ function reportDownloadError(
167
+ onError: ((error: Error) => void) | undefined,
168
+ error: unknown,
169
+ ): void {
170
+ onError?.(
171
+ error instanceof Error ? error : new Error('File download failed.'),
172
+ );
173
+ }
174
+
175
+ // A blob URL is same-origin, so the frame must receive the bytes as a PDF and
176
+ // never as a document the browser would render and run. This retyping is the
177
+ // safeguard; refusing an active markup response turns an HTML answer, such as
178
+ // a login page, into an error that names the cause instead of a broken viewer.
179
+ function asPdfBlob(blob: Blob): Blob {
180
+ return blob.type === PDF_MIME_TYPE
181
+ ? blob
182
+ : blob.slice(0, blob.size, PDF_MIME_TYPE);
183
+ }
184
+
185
+ function PreviewBody({
186
+ file,
187
+ onDownload,
188
+ }: {
189
+ file: FileRecord;
190
+ onDownload?: () => void;
191
+ }): ReactElement {
192
+ const sourceUrl = resolveSafeFileUrl(file.contentUrl ?? '');
193
+ const [text, setText] = useState<string>();
194
+ const [blobUrl, setBlobUrl] = useState<string>();
195
+ const [error, setError] = useState<string | undefined>(() =>
196
+ !sourceUrl ? 'File URL is missing or not allowed.' : undefined,
197
+ );
198
+ const kind: FilePreviewKind = useMemo(
199
+ () => resolveFilePreviewKind(file),
200
+ [file],
201
+ );
202
+ // The content route downloads PDFs as attachments; a local blob can be embedded.
203
+ useEffect(() => {
204
+ if (!sourceUrl || kind !== 'pdf') return undefined;
205
+ const controller = new AbortController();
206
+ let objectUrl: string | undefined;
207
+ void fetch(sourceUrl, {
208
+ credentials: fileUrlCredentials(sourceUrl),
209
+ signal: controller.signal,
210
+ })
211
+ .then(async (response) => {
212
+ if (!response.ok) throw new Error(PDF_PREVIEW_ERROR);
213
+ const blob = await response.blob();
214
+ if (controller.signal.aborted) return;
215
+ if (isActiveMarkupMimeType(blob.type))
216
+ throw new Error(PDF_MARKUP_RESPONSE_ERROR);
217
+ objectUrl = URL.createObjectURL(asPdfBlob(blob));
218
+ setBlobUrl(objectUrl);
219
+ })
220
+ .catch((cause: unknown) => {
221
+ if (!controller.signal.aborted)
222
+ setError(cause instanceof Error ? cause.message : PDF_PREVIEW_ERROR);
223
+ });
224
+ return () => {
225
+ controller.abort();
226
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
227
+ };
228
+ }, [sourceUrl, kind]);
229
+ const url = kind === 'pdf' ? blobUrl : sourceUrl;
230
+ useEffect(() => {
231
+ if (!url || !['text', 'markdown'].includes(kind)) return undefined;
232
+ const controller = new AbortController();
233
+ void fetch(url, {
234
+ credentials: fileUrlCredentials(url),
235
+ signal: controller.signal,
236
+ })
237
+ .then((response) => {
238
+ if (!response.ok)
239
+ throw new Error(`Preview request failed (${response.status}).`);
240
+ return response.text();
241
+ })
242
+ .then(setText)
243
+ .catch((cause: unknown) => {
244
+ if (!(cause instanceof DOMException && cause.name === 'AbortError'))
245
+ setError(
246
+ cause instanceof Error
247
+ ? cause.message
248
+ : 'Unable to load the file preview.',
249
+ );
250
+ });
251
+ return () => controller.abort();
252
+ }, [file, kind, url]);
253
+ return (
254
+ <FilePreviewContent
255
+ file={file}
256
+ kind={kind}
257
+ url={url}
258
+ text={text}
259
+ error={error}
260
+ onDownload={onDownload}
261
+ />
262
+ );
263
+ }
@@ -0,0 +1,68 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { useState, type ReactElement } from 'react';
3
+ import type { FilePreviewFieldProps } from '../types';
4
+ import { Button } from '#components/ui/button';
5
+ import { FilePreviewDialog } from './file-preview-dialog';
6
+ import { FileThumbnail } from './file-thumbnail';
7
+
8
+ export function FilePreviewField(
9
+ inputProps: FilePreviewFieldProps,
10
+ ): ReactElement {
11
+ const { t } = useTranslation('@nocobase/app-plugin-file');
12
+ const {
13
+ files,
14
+ labels,
15
+ emptyState,
16
+ showFilenames = false,
17
+ onError,
18
+ } = inputProps;
19
+
20
+ const [open, setOpen] = useState(false);
21
+ const [initialIndex, setInitialIndex] = useState(0);
22
+ if (!files.length)
23
+ return (
24
+ <>
25
+ {emptyState ?? (
26
+ <span role='status'>
27
+ {t('files.empty', { defaultValue: 'No files.' })}
28
+ </span>
29
+ )}
30
+ </>
31
+ );
32
+ return (
33
+ <>
34
+ <div data-slot='file-preview-field' className='flex flex-wrap gap-2'>
35
+ {files.map((file, index) => (
36
+ <div key={file.id} className='flex max-w-36 flex-col gap-1'>
37
+ <Button
38
+ type='button'
39
+ variant='ghost'
40
+ size='icon'
41
+ className='h-12 w-12 overflow-hidden'
42
+ aria-label={`${labels?.preview ?? t('files.preview', { defaultValue: 'Preview' })}: ${file.filename}`}
43
+ onClick={() => {
44
+ setInitialIndex(index);
45
+ setOpen(true);
46
+ }}
47
+ >
48
+ <FileThumbnail file={file} />
49
+ </Button>
50
+ {showFilenames ? (
51
+ <span className='truncate text-xs' title={file.filename}>
52
+ {file.filename}
53
+ </span>
54
+ ) : null}
55
+ </div>
56
+ ))}
57
+ </div>
58
+ <FilePreviewDialog
59
+ files={files}
60
+ initialIndex={initialIndex}
61
+ open={open}
62
+ onOpenChange={setOpen}
63
+ labels={labels}
64
+ onError={onError}
65
+ />
66
+ </>
67
+ );
68
+ }