@striae-org/striae 3.0.4

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 (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. package/wrangler.toml.example +8 -0
@@ -0,0 +1,330 @@
1
+ import { User } from 'firebase/auth';
2
+ import { useState, useRef, useEffect, useCallback } from 'react';
3
+ import styles from './image-upload-zone.module.css';
4
+ import { uploadFile } from '~/components/actions/image-manage';
5
+ import { FileData } from '~/types';
6
+
7
+ interface ImageUploadZoneProps {
8
+ user: User;
9
+ currentCase: string | null;
10
+ isReadOnly: boolean;
11
+ canUploadNewFile: boolean;
12
+ uploadFileError: string;
13
+ onFilesChanged: (files: FileData[]) => void;
14
+ onUploadPermissionCheck?: (fileCount: number) => Promise<void>;
15
+ currentFiles: FileData[];
16
+ onUploadStatusChange?: (isUploading: boolean) => void;
17
+ onUploadComplete?: (result: { successCount: number; failedFiles: string[] }) => void;
18
+ }
19
+
20
+ const ALLOWED_TYPES = [
21
+ 'image/png',
22
+ 'image/gif',
23
+ 'image/jpeg',
24
+ 'image/webp',
25
+ 'image/svg+xml'
26
+ ];
27
+
28
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
29
+
30
+ export const ImageUploadZone = ({
31
+ user,
32
+ currentCase,
33
+ isReadOnly,
34
+ canUploadNewFile,
35
+ uploadFileError,
36
+ onFilesChanged,
37
+ onUploadPermissionCheck,
38
+ currentFiles,
39
+ onUploadStatusChange,
40
+ onUploadComplete,
41
+ }: ImageUploadZoneProps) => {
42
+ const [isUploadingFile, setIsUploadingFile] = useState(false);
43
+ const [uploadProgress, setUploadProgress] = useState(0);
44
+ const [fileError, setFileError] = useState('');
45
+ const [isDraggingFiles, setIsDraggingFiles] = useState(false);
46
+ const [uploadQueue, setUploadQueue] = useState<File[]>([]);
47
+ const [currentFileIndex, setCurrentFileIndex] = useState(0);
48
+ const [currentFileName, setCurrentFileName] = useState('');
49
+
50
+ const fileInputRef = useRef<HTMLInputElement>(null);
51
+ const dropZoneRef = useRef<HTMLDivElement>(null);
52
+ const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
53
+ const isMountedRef = useRef(true);
54
+ const currentFilesRef = useRef(currentFiles);
55
+
56
+ // Keep currentFilesRef in sync with prop to avoid stale closure
57
+ useEffect(() => {
58
+ currentFilesRef.current = currentFiles;
59
+ }, [currentFiles]);
60
+
61
+ // Notify parent when upload status changes
62
+ useEffect(() => {
63
+ onUploadStatusChange?.(isUploadingFile);
64
+ }, [isUploadingFile, onUploadStatusChange]);
65
+
66
+ // Cleanup on unmount
67
+ useEffect(() => {
68
+ return () => {
69
+ isMountedRef.current = false;
70
+ // Clear any pending timeout
71
+ if (timeoutIdRef.current) {
72
+ clearTimeout(timeoutIdRef.current);
73
+ }
74
+ };
75
+ }, []);
76
+
77
+ // Helper to set error with auto-dismiss, managing timeout properly
78
+ const setErrorWithAutoDismiss = (errorMessage: string) => {
79
+ // Clear any pending timeout from previous error
80
+ if (timeoutIdRef.current) {
81
+ clearTimeout(timeoutIdRef.current);
82
+ }
83
+ setFileError(errorMessage);
84
+ // Set new timeout for auto-dismiss
85
+ timeoutIdRef.current = setTimeout(() => {
86
+ if (!isMountedRef.current) {
87
+ return;
88
+ }
89
+ setFileError('');
90
+ timeoutIdRef.current = null;
91
+ }, 3000);
92
+ };
93
+
94
+ const validateAndUploadFile = async (file: File, currentFilesList: FileData[]) => {
95
+ if (!file || !currentCase || !user || !user.uid) return { success: false, files: currentFilesList };
96
+
97
+ if (!ALLOWED_TYPES.includes(file.type)) {
98
+ if (isMountedRef.current) {
99
+ setErrorWithAutoDismiss(`${file.name}: Only PNG, GIF, JPEG, WEBP, or SVG files are allowed`);
100
+ }
101
+ return { success: false, files: currentFilesList };
102
+ }
103
+
104
+ if (file.size > MAX_FILE_SIZE) {
105
+ if (isMountedRef.current) {
106
+ setErrorWithAutoDismiss(`${file.name}: File size must be less than 10 MB`);
107
+ }
108
+ return { success: false, files: currentFilesList };
109
+ }
110
+
111
+ try {
112
+ if (isMountedRef.current) {
113
+ setCurrentFileName(file.name);
114
+ }
115
+ const uploadedFile = await uploadFile(user, currentCase, file, (progress) => {
116
+ if (isMountedRef.current) {
117
+ setUploadProgress(progress);
118
+ }
119
+ });
120
+ const updatedFiles = [...currentFilesList, uploadedFile];
121
+
122
+ if (isMountedRef.current) {
123
+ onFilesChanged(updatedFiles);
124
+ if (fileInputRef.current) fileInputRef.current.value = '';
125
+ }
126
+
127
+ // Refresh file upload permissions after successful upload
128
+ if (onUploadPermissionCheck && isMountedRef.current) {
129
+ try {
130
+ await onUploadPermissionCheck(updatedFiles.length);
131
+ } catch (permissionErr) {
132
+ console.error('Failed to refresh upload permissions:', permissionErr);
133
+ // Note: Files have already been successfully uploaded.
134
+ // This error is non-critical but should be tracked in monitoring.
135
+ // In production, consider showing a non-blocking warning notification.
136
+ }
137
+ }
138
+ return { success: true, files: updatedFiles };
139
+ } catch (err) {
140
+ if (isMountedRef.current) {
141
+ setErrorWithAutoDismiss(`${file.name}: ${err instanceof Error ? err.message : 'Upload failed'}`);
142
+ }
143
+ return { success: false, files: currentFilesList };
144
+ }
145
+ };
146
+
147
+ // Process files sequentially
148
+ const processFileQueue = async (filesToProcess: File[]) => {
149
+ // Clear any pending timeout from previous errors
150
+ if (timeoutIdRef.current) {
151
+ clearTimeout(timeoutIdRef.current);
152
+ timeoutIdRef.current = null;
153
+ }
154
+
155
+ if (!isMountedRef.current) return;
156
+
157
+ setUploadQueue(filesToProcess);
158
+ setCurrentFileIndex(0);
159
+ setIsUploadingFile(true);
160
+ setFileError('');
161
+ setUploadProgress(0);
162
+
163
+ // Use ref to get current files, avoiding stale closure issues
164
+ let accumulatedFiles = currentFilesRef.current;
165
+ const successfulUploads: string[] = [];
166
+ const failedUploads: string[] = [];
167
+
168
+ for (let i = 0; i < filesToProcess.length; i++) {
169
+ if (!isMountedRef.current) break;
170
+
171
+ setCurrentFileIndex(i);
172
+ setUploadProgress(0);
173
+ const file = filesToProcess[i];
174
+ const result = await validateAndUploadFile(file, accumulatedFiles);
175
+
176
+ if (result.success) {
177
+ accumulatedFiles = result.files;
178
+ successfulUploads.push(file.name);
179
+ } else {
180
+ failedUploads.push(file.name);
181
+ }
182
+ }
183
+
184
+ if (isMountedRef.current) {
185
+ setIsUploadingFile(false);
186
+ setUploadProgress(0);
187
+ setCurrentFileName('');
188
+ setUploadQueue([]);
189
+ setCurrentFileIndex(0);
190
+
191
+ // Call completion callback with results
192
+ if (onUploadComplete && (successfulUploads.length > 0 || failedUploads.length > 0)) {
193
+ onUploadComplete({
194
+ successCount: successfulUploads.length,
195
+ failedFiles: failedUploads
196
+ });
197
+ }
198
+ }
199
+ };
200
+
201
+ const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
202
+ if (isReadOnly) {
203
+ return;
204
+ }
205
+
206
+ const files = event.target.files;
207
+ if (!files || files.length === 0 || !currentCase) return;
208
+
209
+ // Convert FileList to Array
210
+ const filesToUpload = Array.from(files);
211
+ await processFileQueue(filesToUpload);
212
+ }, [isReadOnly, currentCase]);
213
+
214
+ const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
215
+ e.preventDefault();
216
+ e.stopPropagation();
217
+ setIsDraggingFiles(true);
218
+ }, []);
219
+
220
+ const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
221
+ e.preventDefault();
222
+ e.stopPropagation();
223
+
224
+ // Only disable drag mode if leaving the entire drop zone
225
+ // Check if relatedTarget (element being entered) is outside the drop zone
226
+ const relatedTarget = e.relatedTarget as HTMLElement | null;
227
+ if (!relatedTarget || !dropZoneRef.current?.contains(relatedTarget)) {
228
+ setIsDraggingFiles(false);
229
+ }
230
+ }, []);
231
+
232
+ const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
233
+ e.preventDefault();
234
+ e.stopPropagation();
235
+ }, []);
236
+
237
+ const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
238
+ e.preventDefault();
239
+ e.stopPropagation();
240
+ setIsDraggingFiles(false);
241
+
242
+ if (isReadOnly) {
243
+ return;
244
+ }
245
+
246
+ const files = e.dataTransfer.files;
247
+ if (files.length === 0 || !currentCase) return;
248
+
249
+ // Convert FileList to Array and process all files
250
+ const filesToUpload = Array.from(files);
251
+ await processFileQueue(filesToUpload);
252
+ }, [isReadOnly, currentCase]);
253
+
254
+ // If read-only or uploads restricted, show only error message
255
+ if (isReadOnly || !canUploadNewFile) {
256
+ return (
257
+ <div className={styles.imageUploadZone}>
258
+ {(isReadOnly || uploadFileError) && (
259
+ <p className={styles.error}>
260
+ {isReadOnly
261
+ ? 'This case is read-only. You cannot upload files.'
262
+ : uploadFileError}
263
+ </p>
264
+ )}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ return (
270
+ <div
271
+ ref={dropZoneRef}
272
+ className={`${styles.imageUploadZone} ${isDraggingFiles ? styles.dragActive : ''}`}
273
+ onDragEnter={handleDragEnter}
274
+ onDragLeave={handleDragLeave}
275
+ onDragOver={handleDragOver}
276
+ onDrop={handleDrop}
277
+ >
278
+ <label htmlFor="file-upload">Upload Images:</label>
279
+ <div className={styles.dragDropHint}>
280
+ <p className={styles.dragDropText}>
281
+ Drag & drop image files here or click below to select
282
+ </p>
283
+ </div>
284
+ <input
285
+ id="file-upload"
286
+ ref={fileInputRef}
287
+ type="file"
288
+ accept="image/png, image/gif, image/jpeg, image/webp, image/svg+xml"
289
+ multiple
290
+ onChange={handleFileInputChange}
291
+ disabled={isUploadingFile || !canUploadNewFile || isReadOnly}
292
+ className={styles.fileInput}
293
+ aria-label="Upload image files"
294
+ title={!canUploadNewFile ? uploadFileError : undefined}
295
+ />
296
+ {isUploadingFile && (
297
+ <>
298
+ <div
299
+ className={styles.progressBar}
300
+ role="progressbar"
301
+ aria-valuemin={0}
302
+ aria-valuemax={100}
303
+ aria-valuenow={uploadProgress}
304
+ aria-valuetext={uploadProgress === 100 ? 'Processing...' : undefined}
305
+ aria-label="Image upload progress"
306
+ >
307
+ <div
308
+ className={styles.progressFill}
309
+ style={{ width: `${uploadProgress}%` }}
310
+ />
311
+ </div>
312
+ <div className={styles.uploadStatusContainer}>
313
+ <span className={styles.uploadingText}>
314
+ {uploadProgress === 100 ? 'Processing...' : `${uploadProgress}%`}
315
+ </span>
316
+ {uploadQueue.length > 1 && (
317
+ <span className={styles.fileCountText}>
318
+ {currentFileIndex + 1} of {uploadQueue.length}
319
+ </span>
320
+ )}
321
+ </div>
322
+ {currentFileName && (
323
+ <p className={styles.currentFileName}>{currentFileName}</p>
324
+ )}
325
+ </>
326
+ )}
327
+ {fileError && <p className={styles.error}>{fileError}</p>}
328
+ </div>
329
+ );
330
+ };
@@ -0,0 +1,131 @@
1
+ import { JSX, createContext, useContext } from 'react';
2
+ import { classes, media } from '~/utils/style';
3
+ import { themes, tokens } from './theme';
4
+
5
+ interface ThemeContextType {
6
+ theme: 'dark' | 'light';
7
+ }
8
+
9
+ export const ThemeContext = createContext<ThemeContextType>({ theme: 'dark' });
10
+
11
+ interface ThemeProviderProps {
12
+ theme?: 'dark' | 'light';
13
+ children: React.ReactNode;
14
+ className?: string;
15
+ as?: keyof JSX.IntrinsicElements | React.ComponentType<unknown>;
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ export const ThemeProvider = ({
20
+ theme = 'dark',
21
+ children,
22
+ className,
23
+ as: Component = 'div',
24
+ ...rest
25
+ }: ThemeProviderProps) => {
26
+ const parentTheme = useTheme();
27
+ const isRootProvider = !parentTheme.theme;
28
+
29
+ return (
30
+ <ThemeContext.Provider
31
+ value={{
32
+ theme,
33
+ }}
34
+ >
35
+ {isRootProvider && children}
36
+ {!isRootProvider && (
37
+ <Component className={classes(className)} data-theme={theme} {...rest}>
38
+ {children}
39
+ </Component>
40
+ )}
41
+ </ThemeContext.Provider>
42
+ );
43
+ };
44
+
45
+ export function useTheme() {
46
+ const currentTheme = useContext(ThemeContext);
47
+ return currentTheme;
48
+ }
49
+
50
+ export function squish(styles: string) {
51
+ return styles.replace(/\s\s+/g, ' ');
52
+ }
53
+
54
+ export function createThemeProperties(theme: { [x: string]: unknown; black?: string; white?: string; bezierFastoutSlowin?: string; durationXS?: string; durationS?: string; durationM?: string; durationL?: string; durationXL?: string; systemFontStack?: string; fontStack?: string; monoFontStack?: string; fontWeightThin?: number; fontWeightLight?: number; fontWeightRegular?: number; fontWeightMedium?: number; fontWeightBold?: number; fontWeightBlack?: number; fontSizeH0?: string; fontSizeH1?: string; fontSizeH2?: string; fontSizeH3?: string; fontSizeH4?: string; fontSizeH5?: string; fontSizeBodyXL?: string; fontSizeBodyL?: string; fontSizeBodyM?: string; fontSizeBodyS?: string; fontSizeBodyXS?: string; lineHeightTitle?: string; lineHeightBody?: string; maxWidthS?: string; maxWidthM?: string; maxWidthL?: string; maxWidthXL?: string; spaceOuter?: string; spaceXS?: string; spaceS?: string; spaceM?: string; spaceL?: string; spaceXL?: string; space2XL?: string; space3XL?: string; space4XL?: string; space5XL?: string; zIndex0?: number; zIndex1?: number; zIndex2?: number; zIndex3?: number; zIndex4?: number; zIndex5?: number; background?: string; backgroundLight?: string; primary?: string; accent?: string; error?: string; text?: string; textTitle?: string; linkColor?: string; textBody?: string; textLight?: string; }) {
55
+ return squish(
56
+ Object.keys(theme)
57
+ .map(key => `--${key}: ${theme[key]};`)
58
+ .join('\n\n')
59
+ );
60
+ }
61
+
62
+ export function createThemeStyleObject(theme: { [x: string]: unknown; }) {
63
+ const style: Record<string, unknown> = {};
64
+
65
+ for (const key of Object.keys(theme)) {
66
+ style[`--${key}`] = theme[key];
67
+ }
68
+
69
+ return style;
70
+ }
71
+
72
+ export function createMediaTokenProperties() {
73
+ return squish(
74
+ Object.keys(media)
75
+ .map(key => {
76
+ return `
77
+ @media (max-width: ${media[key]}px) {
78
+ :root {
79
+ ${createThemeProperties(tokens[key as keyof typeof tokens])}
80
+ }
81
+ }
82
+ `;
83
+ })
84
+ .join('\n')
85
+ );
86
+ }
87
+
88
+ const layerStyles = squish(`
89
+ @layer theme, base, components, layout;
90
+ `);
91
+
92
+ const tokenStyles = squish(`
93
+ :root {
94
+ ${createThemeProperties(tokens.base)}
95
+ }
96
+
97
+ ${createMediaTokenProperties()}
98
+
99
+ [data-theme='dark'] {
100
+ ${createThemeProperties(themes.dark)}
101
+ }
102
+
103
+ [data-theme='light'] {
104
+ ${createThemeProperties(themes.light)}
105
+ }
106
+ `);
107
+
108
+ const fontStyles = squish(`
109
+ @font-face {
110
+ font-family: 'Inter';
111
+ font-style: normal;
112
+ font-weight: 100 900;
113
+ font-display: swap;
114
+ src: url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
115
+ }
116
+
117
+ body {
118
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
119
+ -webkit-font-smoothing: antialiased;
120
+ -moz-osx-font-smoothing: grayscale;
121
+ }
122
+ `);
123
+
124
+ export const themeStyles = squish(`
125
+ ${layerStyles}
126
+
127
+ @layer theme {
128
+ ${tokenStyles}
129
+ ${fontStyles}
130
+ }
131
+ `);
@@ -0,0 +1,155 @@
1
+ import { pxToRem } from '~/utils/style';
2
+
3
+ // Full list of tokens
4
+ const baseTokens = {
5
+ black: 'oklch(0% 0 0)',
6
+ white: 'oklch(100% 0 0)',
7
+ bezierFastoutSlowin: 'cubic-bezier(0.4, 0.0, 0.2, 1)',
8
+ durationXS: '200ms',
9
+ durationS: '300ms',
10
+ durationM: '400ms',
11
+ durationL: '600ms',
12
+ durationXL: '800ms',
13
+ systemFontStack:
14
+ 'system-ui, -apple-system, BlinkMacSystemFont, San Francisco, Roboto, Segoe UI, Ubuntu, Helvetica Neue, sans-serif',
15
+ fontStack: `var(--systemFontStack)`,
16
+ monoFontStack:
17
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',
18
+ fontWeightThin: 100,
19
+ fontWeightLight: 300,
20
+ fontWeightRegular: 400,
21
+ fontWeightMedium: 500,
22
+ fontWeightBold: 700,
23
+ fontWeightBlack: 900,
24
+ fontSizeH0: pxToRem(140),
25
+ fontSizeH1: pxToRem(100),
26
+ fontSizeH2: pxToRem(58),
27
+ fontSizeH3: pxToRem(38),
28
+ fontSizeH4: pxToRem(28),
29
+ fontSizeH5: pxToRem(24),
30
+ fontSizeBodyXL: pxToRem(22),
31
+ fontSizeBodyL: pxToRem(20),
32
+ fontSizeBodyM: pxToRem(18),
33
+ fontSizeBodyS: pxToRem(16),
34
+ fontSizeBodyXS: pxToRem(14),
35
+ lineHeightTitle: '1.1',
36
+ lineHeightBody: '1.6',
37
+ maxWidthS: '540px',
38
+ maxWidthM: '720px',
39
+ maxWidthL: '1096px',
40
+ maxWidthXL: '1680px',
41
+ spaceOuter: '64px',
42
+ spaceXS: '4px',
43
+ spaceS: '8px',
44
+ spaceM: '16px',
45
+ spaceL: '24px',
46
+ spaceXL: '32px',
47
+ space2XL: '48px',
48
+ space3XL: '64px',
49
+ space4XL: '96px',
50
+ space5XL: '128px',
51
+ radiusS: '4px',
52
+ radiusM: '8px',
53
+ radiusL: '12px',
54
+ zIndex0: 0,
55
+ zIndex1: 4,
56
+ zIndex2: 8,
57
+ zIndex3: 16,
58
+ zIndex4: 32,
59
+ zIndex5: 64,
60
+ };
61
+
62
+ // Tokens that change based on viewport size
63
+ const tokensDesktop = {
64
+ fontSizeH0: pxToRem(120),
65
+ fontSizeH1: pxToRem(80),
66
+ };
67
+
68
+ const tokensLaptop = {
69
+ maxWidthS: '480px',
70
+ maxWidthM: '640px',
71
+ maxWidthL: '1000px',
72
+ maxWidthXL: '1100px',
73
+ spaceOuter: '48px',
74
+ fontSizeH0: pxToRem(100),
75
+ fontSizeH1: pxToRem(70),
76
+ fontSizeH2: pxToRem(50),
77
+ fontSizeH3: pxToRem(36),
78
+ fontSizeH4: pxToRem(26),
79
+ fontSizeH5: pxToRem(22),
80
+ };
81
+
82
+ const tokensTablet = {
83
+ fontSizeH0: pxToRem(80),
84
+ fontSizeH1: pxToRem(60),
85
+ fontSizeH2: pxToRem(48),
86
+ fontSizeH3: pxToRem(32),
87
+ fontSizeH4: pxToRem(24),
88
+ fontSizeH5: pxToRem(20),
89
+ };
90
+
91
+ const tokensMobile = {
92
+ spaceOuter: '24px',
93
+ fontSizeH0: pxToRem(56),
94
+ fontSizeH1: pxToRem(40),
95
+ fontSizeH2: pxToRem(34),
96
+ fontSizeH3: pxToRem(28),
97
+ fontSizeH4: pxToRem(22),
98
+ fontSizeH5: pxToRem(18),
99
+ fontSizeBodyL: pxToRem(17),
100
+ fontSizeBodyM: pxToRem(16),
101
+ fontSizeBodyS: pxToRem(14),
102
+ };
103
+
104
+ const tokensMobileSmall = {
105
+ spaceOuter: '16px',
106
+ fontSizeH0: pxToRem(42),
107
+ fontSizeH1: pxToRem(38),
108
+ fontSizeH2: pxToRem(28),
109
+ fontSizeH3: pxToRem(24),
110
+ fontSizeH4: pxToRem(20),
111
+ };
112
+
113
+ // Tokens that change based on theme
114
+ const dark = {
115
+ background: 'oklch(96.12% 0 0)',
116
+ backgroundLight: 'var(--white)',
117
+ primary: 'oklch(57.06% 0.162 252.34)',
118
+ accent: 'oklch(48.88% 0.131 146.01)',
119
+ success: 'oklch(70% 0.131 146.01)',
120
+ error: 'oklch(63.17% 0.259 25.41)',
121
+ errorLight: 'oklch(90% 0.259 25.41)',
122
+ warning: 'oklch(55% 0.15 55)',
123
+ text: 'var(--black)',
124
+ textTitle: 'rgba(0, 0, 0, 0.9)', // Fallback for Firefox
125
+ linkColor: 'var(--accent)',
126
+ textBody: 'rgba(0, 0, 0, 0.75)', // Fallback for Firefox
127
+ textLight: 'rgba(0, 0, 0, 0.55)', // Fallback for Firefox
128
+ };
129
+
130
+ const light = {
131
+ background: 'oklch(96.12% 0 0)',
132
+ backgroundLight: 'var(--white)',
133
+ primary: 'oklch(57.06% 0.162 252.34)',
134
+ accent: 'oklch(48.88% 0.131 146.01)',
135
+ success: 'oklch(70% 0.131 146.01)',
136
+ error: 'oklch(63.17% 0.259 25.41)',
137
+ errorLight: 'oklch(90% 0.259 25.41)',
138
+ warning: 'oklch(55% 0.15 55)',
139
+ text: 'var(--black)',
140
+ textTitle: 'rgba(0, 0, 0, 0.9)', // Fallback for Firefox
141
+ linkColor: 'var(--accent)',
142
+ textBody: 'rgba(0, 0, 0, 0.75)', // Fallback for Firefox
143
+ textLight: 'rgba(0, 0, 0, 0.55)', // Fallback for Firefox
144
+ };
145
+
146
+ export const tokens = {
147
+ base: baseTokens,
148
+ desktop: tokensDesktop,
149
+ laptop: tokensLaptop,
150
+ tablet: tokensTablet,
151
+ mobile: tokensMobile,
152
+ mobileS: tokensMobileSmall,
153
+ };
154
+
155
+ export const themes = { dark, light };