@tuturuuu/ui 0.30.2 → 0.31.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  16. package/src/components/ui/custom/nav-link.tsx +7 -7
  17. package/src/components/ui/custom/nav.tsx +15 -63
  18. package/src/components/ui/custom/notification-card.tsx +361 -0
  19. package/src/components/ui/custom/notification-list.tsx +178 -0
  20. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  21. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  22. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  23. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  24. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  25. package/src/components/ui/custom/satellite-content.tsx +148 -0
  26. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  27. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  28. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  29. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  30. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  31. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  32. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  33. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  34. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  35. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  36. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  37. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  38. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  39. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  40. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  41. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  42. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  43. package/src/components/ui/custom/structure.tsx +14 -0
  44. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  45. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  46. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  47. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  48. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  49. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  50. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  51. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  53. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  54. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  56. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  57. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  58. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  59. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  61. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  62. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  63. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  65. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  66. package/src/components/ui/pwa-status.test.tsx +53 -0
  67. package/src/components/ui/pwa-status.tsx +113 -0
  68. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  69. package/src/components/ui/report-problem-dialog.tsx +6 -975
  70. package/src/components/ui/report-problem-model.ts +133 -0
  71. package/src/components/ui/report-problem-mutation.ts +122 -0
  72. package/src/components/ui/save-notification.test.tsx +36 -0
  73. package/src/components/ui/save-notification.tsx +17 -0
  74. package/src/components/ui/use-report-problem.ts +488 -0
  75. package/src/declarations.d.ts +2 -0
  76. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  77. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  78. package/src/hooks/calendar-date-utils.ts +11 -0
  79. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  80. package/src/hooks/calendar-event-lookup.ts +47 -0
  81. package/src/hooks/calendar-range-cache.ts +52 -0
  82. package/src/hooks/pwa-install-prompt.ts +44 -0
  83. package/src/hooks/use-calendar-sync.tsx +23 -100
  84. package/src/hooks/use-calendar.tsx +21 -77
  85. package/src/hooks/use-notifications.ts +9 -24
  86. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  87. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  88. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  89. package/src/hooks/use-workspace-presence.ts +0 -18
  90. package/src/lib/tasks-app-url.test.ts +25 -0
  91. package/src/lib/tasks-app-url.ts +8 -0
@@ -1,980 +1,11 @@
1
1
  'use client';
2
-
3
- import { useMutation } from '@tanstack/react-query';
4
- import {
5
- AlertCircle,
6
- AlertTriangle,
7
- CheckCircle,
8
- MessageSquareWarning,
9
- Upload,
10
- X,
11
- } from '@tuturuuu/icons';
12
- import {
13
- createReportUploadUrls,
14
- deleteReportUploadPaths,
15
- submitReport,
16
- } from '@tuturuuu/internal-api/reports';
17
- import type { Product, SupportType } from '@tuturuuu/types';
18
- import { Button } from '@tuturuuu/ui/button';
19
- import {
20
- Dialog,
21
- DialogContent,
22
- DialogDescription,
23
- DialogHeader,
24
- DialogTitle,
25
- DialogTrigger,
26
- } from '@tuturuuu/ui/dialog';
27
- import { Input } from '@tuturuuu/ui/input';
28
- import { Label } from '@tuturuuu/ui/label';
29
- import {
30
- Select,
31
- SelectContent,
32
- SelectItem,
33
- SelectTrigger,
34
- SelectValue,
35
- } from '@tuturuuu/ui/select';
36
- import { toast } from '@tuturuuu/ui/sonner';
37
- import { Textarea } from '@tuturuuu/ui/textarea';
38
- import { cn, isValidBlobUrl } from '@tuturuuu/utils/format';
39
- import imageCompression from 'browser-image-compression';
40
2
  import Image from 'next/image';
41
3
  import { useTranslations } from 'next-intl';
42
- import type React from 'react';
43
- import { useEffect, useId, useRef, useState } from 'react';
44
- import { z } from 'zod';
45
-
46
- interface ReportProblemFormData {
47
- product: Product | '';
48
- type: SupportType | '';
49
- suggestion: string;
50
- media: File[];
51
- }
52
-
53
- const MAX_MEDIA_SIZE = 5 * 1024 * 1024; // 5MB
54
- const MAX_FILES = 5;
55
-
56
- // Allowed media types
57
- const ALLOWED_IMAGE_TYPES = [
58
- 'image/png',
59
- 'image/jpeg',
60
- 'image/webp',
61
- 'image/gif',
62
- ];
63
- const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
64
-
65
- interface SubmitReportMutationInput {
66
- product: Product;
67
- type: SupportType;
68
- suggestion: string;
69
- subject: string;
70
- media: File[];
71
- }
72
-
73
- function useSubmitReportMutation() {
74
- return useMutation({
75
- mutationFn: async ({
76
- product,
77
- type,
78
- suggestion,
79
- subject,
80
- media,
81
- }: SubmitReportMutationInput) => {
82
- const uploadedPaths: string[] = [];
83
-
84
- const cleanupUploadedMedia = async () => {
85
- if (uploadedPaths.length === 0) {
86
- return;
87
- }
88
-
89
- try {
90
- await deleteReportUploadPaths({ paths: uploadedPaths });
91
- } catch (cleanupError) {
92
- console.error(
93
- 'Failed to clean up uploaded support report media:',
94
- cleanupError
95
- );
96
- }
97
- };
98
-
99
- try {
100
- if (media.length > 0) {
101
- const { uploads } = await createReportUploadUrls({
102
- files: media.map((file) => ({
103
- filename: file.name,
104
- contentType: file.type,
105
- size: file.size,
106
- })),
107
- });
108
-
109
- if (uploads.length !== media.length) {
110
- throw new Error('Failed to prepare media uploads');
111
- }
112
-
113
- for (const [index, upload] of uploads.entries()) {
114
- const file = media[index];
115
-
116
- if (!file) {
117
- throw new Error('Missing media file during upload');
118
- }
119
-
120
- let uploadResponse = await fetch(upload.signedUrl, {
121
- method: 'PUT',
122
- cache: 'no-store',
123
- headers: {
124
- Authorization: `Bearer ${upload.token}`,
125
- 'Content-Type': file.type || 'application/octet-stream',
126
- },
127
- body: file,
128
- });
129
-
130
- if (!uploadResponse.ok) {
131
- uploadResponse = await fetch(upload.signedUrl, {
132
- method: 'PUT',
133
- cache: 'no-store',
134
- headers: {
135
- Authorization: `Bearer ${upload.token}`,
136
- },
137
- body: file,
138
- });
139
- }
140
-
141
- if (!uploadResponse.ok) {
142
- const text = await uploadResponse.text().catch(() => '');
143
- throw new Error(
144
- `Failed to upload media (${uploadResponse.status})${text ? `: ${text}` : ''}`
145
- );
146
- }
147
-
148
- uploadedPaths.push(upload.path);
149
- }
150
- }
151
-
152
- const result = await submitReport({
153
- product,
154
- type,
155
- suggestion,
156
- subject,
157
- imagePaths: uploadedPaths,
158
- });
159
-
160
- if (!result.success) {
161
- throw new Error(result.message || 'Failed to submit report');
162
- }
163
-
164
- return result;
165
- } catch (error) {
166
- await cleanupUploadedMedia();
167
- throw error;
168
- }
169
- },
170
- });
171
- }
172
-
173
- // Zod schema for form validation
174
- const reportProblemSchema = z.object({
175
- product: z.enum(
176
- [
177
- 'web',
178
- 'nova',
179
- 'rewise',
180
- 'calendar',
181
- 'finance',
182
- 'tudo',
183
- 'tumeet',
184
- 'shortener',
185
- 'qr',
186
- 'drive',
187
- 'mail',
188
- 'other',
189
- ],
190
- {
191
- message: 'Please select a product',
192
- }
193
- ),
194
- type: z.enum(['bug', 'feature-request'], {
195
- message: 'Please select a support type',
196
- }),
197
- suggestion: z
198
- .string()
199
- .trim()
200
- .min(1, 'Please describe the issue or suggestion')
201
- .max(1000, 'Suggestion must be at most 1000 characters'),
202
- media: z
203
- .array(z.instanceof(File))
204
- .max(MAX_FILES, `You can upload up to ${MAX_FILES} files`)
205
- .refine(
206
- (files) =>
207
- files.every(
208
- (f) =>
209
- ALLOWED_IMAGE_TYPES.includes(f.type) ||
210
- ALLOWED_VIDEO_TYPES.includes(f.type)
211
- ),
212
- {
213
- message:
214
- 'Only image (PNG, JPEG, WebP, GIF) and video (MP4, WebM, MOV) files are allowed',
215
- }
216
- )
217
- .refine((files) => files.every((f) => f.size <= MAX_MEDIA_SIZE), {
218
- message: 'Each file must be 5MB or less',
219
- }),
220
- });
221
-
222
- interface ReportProblemDialogProps {
223
- open?: boolean;
224
- onOpenChange?: (open: boolean) => void;
225
- products?: Array<{ value: Product; label: string }>;
226
- className?: string;
227
- trigger?: React.ReactNode;
228
- showTrigger?: boolean;
229
- }
230
-
231
- const DEFAULT_PRODUCTS: Array<{ value: Product; label: string }> = [
232
- { value: 'web', label: 'Web Dashboard' },
233
- { value: 'nova', label: 'Nova' },
234
- { value: 'rewise', label: 'Rewise' },
235
- { value: 'calendar', label: 'Calendar' },
236
- { value: 'finance', label: 'Finance' },
237
- { value: 'tudo', label: 'Tuturuuu Tasks' },
238
- { value: 'tumeet', label: 'Tuturuuu Meet' },
239
- { value: 'shortener', label: 'URL Shortener' },
240
- { value: 'qr', label: 'QR Code' },
241
- { value: 'drive', label: 'Drive' },
242
- { value: 'mail', label: 'Mail' },
243
- { value: 'other', label: 'Other' },
244
- ];
245
-
246
- const DEFAULT_SUPPORT_TYPES: Array<{ value: SupportType; label: string }> = [
247
- { value: 'bug', label: 'Bug Report' },
248
- { value: 'feature-request', label: 'Feature Request' },
249
- ];
250
-
251
- const LOCAL_STORAGE_KEY = 'report-problem-form-data';
252
-
253
- export function ReportProblemDialog({
254
- open,
255
- onOpenChange,
256
- products = DEFAULT_PRODUCTS,
257
- className,
258
- trigger,
259
- showTrigger = true,
260
- }: ReportProblemDialogProps) {
4
+ import { ReportProblemDialogContent } from './report-problem-dialog-content';
5
+ import type { ReportProblemDialogProps } from './report-problem-model';
6
+ export function ReportProblemDialog(
7
+ props: Omit<ReportProblemDialogProps, 't' | 'apiOptions'>
8
+ ) {
261
9
  const t = useTranslations('common');
262
- const suggestionId = useId();
263
- const mediaUploadId = useId();
264
-
265
- // Internal state for uncontrolled mode
266
- const [internalOpen, setInternalOpen] = useState(false);
267
-
268
- // Use controlled or uncontrolled mode
269
- const isControlled = open !== undefined;
270
- const dialogOpen = isControlled ? open : internalOpen;
271
-
272
- const [formData, setFormData] = useState<ReportProblemFormData>({
273
- product: '',
274
- type: '',
275
- suggestion: '',
276
- media: [],
277
- });
278
- const [mediaPreviews, setMediaPreviews] = useState<string[]>([]);
279
- const [isCompressing, setIsCompressing] = useState(false);
280
- const [validationErrors, setValidationErrors] = useState<{
281
- product?: string;
282
- type?: string;
283
- suggestion?: string;
284
- media?: string;
285
- }>({});
286
- const [isDragOver, setIsDragOver] = useState(false);
287
- const fileInputRef = useRef<HTMLInputElement>(null);
288
- const mediaPreviewsRef = useRef<string[]>([]);
289
- const submitReportMutation = useSubmitReportMutation();
290
- const isSubmitting = submitReportMutation.isPending;
291
-
292
- const validateForm = () => {
293
- const result = reportProblemSchema.safeParse(formData);
294
- if (result.success) {
295
- setValidationErrors({});
296
- return true;
297
- }
298
-
299
- const fieldErrors: {
300
- product?: string;
301
- type?: string;
302
- suggestion?: string;
303
- media?: string;
304
- } = {};
305
- for (const issue of result.error.issues) {
306
- if (issue.path[0] === 'product' && !fieldErrors.product) {
307
- fieldErrors.product = issue.message;
308
- }
309
- if (issue.path[0] === 'type' && !fieldErrors.type) {
310
- fieldErrors.type = issue.message;
311
- }
312
- if (issue.path[0] === 'suggestion' && !fieldErrors.suggestion) {
313
- fieldErrors.suggestion = issue.message;
314
- }
315
- if (issue.path[0] === 'media' && !fieldErrors.media) {
316
- fieldErrors.media = issue.message;
317
- }
318
- }
319
- setValidationErrors(fieldErrors);
320
- return false;
321
- };
322
-
323
- const handleProductChange = (value: string) => {
324
- setFormData((prev) => ({ ...prev, product: value as Product }));
325
- if (validationErrors.product) {
326
- setValidationErrors((prev) => ({ ...prev, product: undefined }));
327
- }
328
- };
329
-
330
- const handleTypeChange = (value: string) => {
331
- setFormData((prev) => ({ ...prev, type: value as SupportType }));
332
- if (validationErrors.type) {
333
- setValidationErrors((prev) => ({ ...prev, type: undefined }));
334
- }
335
- };
336
-
337
- const handleSuggestionChange = (
338
- event: React.ChangeEvent<HTMLTextAreaElement>
339
- ) => {
340
- setFormData((prev) => ({ ...prev, suggestion: event.target.value }));
341
- if (validationErrors.suggestion && event.target.value.trim()) {
342
- setValidationErrors((prev) => ({ ...prev, suggestion: undefined }));
343
- }
344
- };
345
-
346
- const handleDragOver = (event: React.DragEvent) => {
347
- event.preventDefault();
348
- if (event.dataTransfer) {
349
- event.dataTransfer.dropEffect = 'copy';
350
- }
351
- setIsDragOver(true);
352
- };
353
- const handleDragLeave = (event: React.DragEvent) => {
354
- event.preventDefault();
355
- setIsDragOver(false);
356
- };
357
-
358
- const handleDrop = (event: React.DragEvent) => {
359
- event.preventDefault();
360
- setIsDragOver(false);
361
-
362
- const files = Array.from(event.dataTransfer.files).filter(
363
- (file) =>
364
- ALLOWED_IMAGE_TYPES.includes(file.type) ||
365
- ALLOWED_VIDEO_TYPES.includes(file.type)
366
- );
367
-
368
- if (files.length > 0) {
369
- processMediaFiles(files);
370
- }
371
- };
372
-
373
- const compressImage = async (file: File): Promise<File> => {
374
- try {
375
- const options = {
376
- maxSizeMB: 5,
377
- maxWidthOrHeight: 1920,
378
- useWebWorker: true,
379
- initialQuality: 0.8,
380
- };
381
- const compressedBlob = await imageCompression(file, options);
382
-
383
- // Convert Blob back to File with original name and type
384
- const compressedFile = new File([compressedBlob], file.name, {
385
- type: compressedBlob.type || file.type,
386
- lastModified: Date.now(),
387
- });
388
-
389
- return compressedFile;
390
- } catch (error) {
391
- console.error('Image compression failed:', error);
392
- return file; // Return original if compression fails
393
- }
394
- };
395
-
396
- const compressVideo = async (file: File): Promise<File> => {
397
- try {
398
- // Create video element to load the file
399
- const video = document.createElement('video');
400
- video.preload = 'metadata';
401
- video.muted = true;
402
-
403
- const videoUrl = URL.createObjectURL(file);
404
- video.src = videoUrl;
405
-
406
- // Wait for video to load metadata
407
- await new Promise((resolve, reject) => {
408
- video.onloadedmetadata = resolve;
409
- video.onerror = reject;
410
- });
411
-
412
- // Calculate target dimensions (max 1280x720 to reduce file size)
413
- let targetWidth = video.videoWidth;
414
- let targetHeight = video.videoHeight;
415
- const maxWidth = 1280;
416
- const maxHeight = 720;
417
-
418
- if (targetWidth > maxWidth || targetHeight > maxHeight) {
419
- const aspectRatio = targetWidth / targetHeight;
420
- if (aspectRatio > maxWidth / maxHeight) {
421
- targetWidth = maxWidth;
422
- targetHeight = Math.round(maxWidth / aspectRatio);
423
- } else {
424
- targetHeight = maxHeight;
425
- targetWidth = Math.round(maxHeight * aspectRatio);
426
- }
427
- }
428
-
429
- // Create canvas for video processing
430
- const canvas = document.createElement('canvas');
431
- canvas.width = targetWidth;
432
- canvas.height = targetHeight;
433
- const ctx = canvas.getContext('2d');
434
-
435
- if (!ctx) {
436
- throw new Error('Could not get canvas context');
437
- }
438
-
439
- // Set up MediaRecorder with compression settings
440
- const stream = canvas.captureStream(30); // 30 FPS
441
- const mimeType = MediaRecorder.isTypeSupported('video/webm; codecs=vp9')
442
- ? 'video/webm; codecs=vp9'
443
- : MediaRecorder.isTypeSupported('video/webm; codecs=vp8')
444
- ? 'video/webm; codecs=vp8'
445
- : 'video/webm';
446
-
447
- const mediaRecorder = new MediaRecorder(stream, {
448
- mimeType,
449
- videoBitsPerSecond: 1000000, // 1 Mbps for good quality with compression
450
- });
451
-
452
- const chunks: Blob[] = [];
453
- mediaRecorder.ondataavailable = (e) => {
454
- if (e.data.size > 0) {
455
- chunks.push(e.data);
456
- }
457
- };
458
-
459
- // Start recording
460
- mediaRecorder.start();
461
-
462
- // Play video and draw frames to canvas
463
- video.currentTime = 0;
464
- await video.play();
465
-
466
- const drawFrame = () => {
467
- if (video.ended || video.paused) {
468
- return;
469
- }
470
- ctx.drawImage(video, 0, 0, targetWidth, targetHeight);
471
- requestAnimationFrame(drawFrame);
472
- };
473
-
474
- drawFrame();
475
-
476
- // Wait for video to finish
477
- await new Promise((resolve) => {
478
- video.onended = resolve;
479
- });
480
-
481
- // Stop recording
482
- mediaRecorder.stop();
483
-
484
- // Wait for final data
485
- const compressedBlob = await new Promise<Blob>((resolve) => {
486
- mediaRecorder.onstop = () => {
487
- resolve(new Blob(chunks, { type: mimeType }));
488
- };
489
- });
490
-
491
- // Clean up
492
- URL.revokeObjectURL(videoUrl);
493
- video.remove();
494
- canvas.remove();
495
-
496
- // Convert to File
497
- const fileExtension = mimeType.includes('webm') ? 'webm' : 'mp4';
498
- const fileName = file.name.replace(/\.[^/.]+$/, `.${fileExtension}`);
499
- const compressedFile = new File([compressedBlob], fileName, {
500
- type: mimeType,
501
- lastModified: Date.now(),
502
- });
503
-
504
- // Only return compressed version if it's actually smaller
505
- return compressedFile.size < file.size ? compressedFile : file;
506
- } catch (error) {
507
- console.error('Video compression failed:', error);
508
- return file; // Return original if compression fails
509
- }
510
- };
511
-
512
- const processMediaFiles = async (files: File[]) => {
513
- // First filter by type only
514
- const validTypeFiles = files.filter(
515
- (file) =>
516
- ALLOWED_IMAGE_TYPES.includes(file.type) ||
517
- ALLOWED_VIDEO_TYPES.includes(file.type)
518
- );
519
-
520
- const invalidTypeCount = files.length - validTypeFiles.length;
521
- if (invalidTypeCount > 0) {
522
- setValidationErrors((prev) => ({
523
- ...prev,
524
- media: `${invalidTypeCount} file(s) rejected: only images (PNG, JPEG, WebP, GIF) and videos (MP4, WebM, MOV) are allowed.`,
525
- }));
526
- }
527
-
528
- const currentMediaCount = formData.media.length;
529
- const availableSlots = MAX_FILES - currentMediaCount;
530
- const filesToProcess = validTypeFiles.slice(0, availableSlots);
531
- const overflow = validTypeFiles.length - filesToProcess.length;
532
-
533
- if (overflow > 0) {
534
- setValidationErrors((prev) => ({
535
- ...prev,
536
- media: `You can upload up to ${MAX_FILES} files. ${overflow} file(s) were rejected.`,
537
- }));
538
- }
539
-
540
- if (filesToProcess.length > 0) {
541
- setIsCompressing(true);
542
- try {
543
- // Compress images and videos first, then check size
544
- const processedWithSize = await Promise.all(
545
- filesToProcess.map(async (file) => {
546
- let processedFile = file;
547
-
548
- // Compress images before size check
549
- if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
550
- processedFile = await compressImage(file);
551
- }
552
- // Compress videos before size check
553
- else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
554
- processedFile = await compressVideo(file);
555
- }
556
-
557
- return {
558
- file: processedFile,
559
- isValid: processedFile.size <= MAX_MEDIA_SIZE,
560
- originalName: file.name,
561
- };
562
- })
563
- );
564
-
565
- // Filter out files that are still too large after compression
566
- const validFiles = processedWithSize.filter((item) => item.isValid);
567
- const rejectedBySize = processedWithSize.filter(
568
- (item) => !item.isValid
569
- );
570
-
571
- if (rejectedBySize.length > 0) {
572
- const rejectedNames = rejectedBySize
573
- .map((item) => item.originalName)
574
- .join(', ');
575
- setValidationErrors((prev) => ({
576
- ...prev,
577
- media: `${rejectedBySize.length} file(s) rejected (exceeded 5MB even after compression): ${rejectedNames}`,
578
- }));
579
- } else if (validationErrors.media && invalidTypeCount === 0) {
580
- setValidationErrors((prev) => ({ ...prev, media: undefined }));
581
- }
582
-
583
- if (validFiles.length > 0) {
584
- const acceptedFiles = validFiles.map((item) => item.file);
585
- const newMedia = [...formData.media, ...acceptedFiles];
586
- setFormData((prev) => ({ ...prev, media: newMedia }));
587
-
588
- // Create preview URLs
589
- const newPreviews = acceptedFiles.map((file) =>
590
- URL.createObjectURL(file)
591
- );
592
- setMediaPreviews((prev) => {
593
- const updated = [...prev, ...newPreviews];
594
- mediaPreviewsRef.current = updated;
595
- return updated;
596
- });
597
- }
598
- } finally {
599
- setIsCompressing(false);
600
- }
601
- }
602
- };
603
-
604
- const handleMediaUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
605
- const files = Array.from(event.target.files || []);
606
- processMediaFiles(files);
607
-
608
- // Reset file input
609
- if (fileInputRef.current) {
610
- fileInputRef.current.value = '';
611
- }
612
- };
613
-
614
- const removeMedia = (index: number) => {
615
- // Revoke the object URL to free memory
616
- if (mediaPreviews[index]) {
617
- URL.revokeObjectURL(mediaPreviews[index]);
618
- }
619
-
620
- const newMedia = formData.media.filter((_, i) => i !== index);
621
- const newPreviews = mediaPreviews.filter((_, i) => i !== index);
622
-
623
- setFormData((prev) => ({ ...prev, media: newMedia }));
624
- setMediaPreviews(newPreviews);
625
- mediaPreviewsRef.current = newPreviews;
626
- };
627
-
628
- const handleSubmit = async (event: React.FormEvent) => {
629
- event.preventDefault();
630
-
631
- if (!validateForm()) {
632
- return;
633
- }
634
-
635
- try {
636
- const validatedForm = reportProblemSchema.parse(formData);
637
-
638
- // Generate a subject based on the type and product
639
- const subject = `${validatedForm.type === 'bug' ? 'Bug Report' : 'Feature Request'} - ${
640
- DEFAULT_PRODUCTS.find((p) => p.value === validatedForm.product)
641
- ?.label || validatedForm.product
642
- }`;
643
-
644
- await submitReportMutation.mutateAsync({
645
- product: validatedForm.product,
646
- type: validatedForm.type,
647
- suggestion: validatedForm.suggestion,
648
- subject,
649
- media: validatedForm.media,
650
- });
651
- toast.success(t('report-submitted-success'));
652
-
653
- // Close dialog (which will also trigger cleanup)
654
- handleOpenChange(false);
655
- } catch (error) {
656
- console.error('Failed to submit report:', error);
657
- toast.error('Failed to submit report. Please try again.');
658
- }
659
- };
660
-
661
- const handleOpenChange = (newOpen: boolean) => {
662
- // Update internal state for uncontrolled mode
663
- if (!isControlled) {
664
- setInternalOpen(newOpen);
665
- }
666
-
667
- // Notify parent if handler exists (controlled mode)
668
- if (onOpenChange) {
669
- onOpenChange(newOpen);
670
- }
671
-
672
- // Clean up and reset everything when dialog closes
673
- if (!newOpen) {
674
- // Revoke all object URLs to free memory
675
- mediaPreviews.forEach((url) => {
676
- URL.revokeObjectURL(url);
677
- });
678
-
679
- // Clear all form state
680
- setFormData({ product: '', type: '', suggestion: '', media: [] });
681
- setMediaPreviews([]);
682
- mediaPreviewsRef.current = [];
683
- setValidationErrors({});
684
- setIsDragOver(false);
685
- setIsCompressing(false);
686
-
687
- // Clear localStorage
688
- localStorage.removeItem(LOCAL_STORAGE_KEY);
689
- }
690
- };
691
-
692
- // Clean up object URLs on unmount
693
- useEffect(() => {
694
- return () => {
695
- mediaPreviewsRef.current.forEach((url) => {
696
- URL.revokeObjectURL(url);
697
- });
698
- };
699
- }, []);
700
-
701
- return (
702
- <Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
703
- {showTrigger && (
704
- <DialogTrigger asChild>
705
- {trigger || (
706
- <Button variant="secondary" size="sm" className={cn(className)}>
707
- <MessageSquareWarning className="mr-2 h-4 w-4" />
708
- {t('report-problem')}
709
- </Button>
710
- )}
711
- </DialogTrigger>
712
- )}
713
- <DialogContent className="mx-auto flex max-h-[90vh] w-[calc(100vw-1.5rem)] max-w-7xl flex-col overflow-hidden">
714
- <DialogHeader className="border-b pb-4">
715
- <DialogTitle className="flex items-center gap-2 text-lg sm:text-xl">
716
- <div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900">
717
- <AlertTriangle className="h-4 w-4 text-orange-600 dark:text-orange-400" />
718
- </div>
719
- {t('report-problem')}
720
- </DialogTitle>
721
- <DialogDescription className="mt-2 text-muted-foreground text-sm">
722
- {t('report-problem-description')}
723
- </DialogDescription>
724
- </DialogHeader>
725
-
726
- <div className="flex-1 overflow-y-auto">
727
- <form onSubmit={handleSubmit} className="space-y-6">
728
- <div className="space-y-3">
729
- <Label
730
- htmlFor="product"
731
- className="flex items-center gap-1 font-medium text-sm"
732
- >
733
- {t('affected-product-required')}
734
- </Label>
735
- <Select
736
- value={formData.product}
737
- onValueChange={handleProductChange}
738
- >
739
- <SelectTrigger
740
- className={cn(
741
- 'h-11',
742
- validationErrors.product &&
743
- 'border-red-500 focus:border-red-500'
744
- )}
745
- >
746
- <SelectValue placeholder={t('select-product-placeholder')} />
747
- </SelectTrigger>
748
- <SelectContent>
749
- {products.map((product) => (
750
- <SelectItem key={product.value} value={product.value}>
751
- {product.label}
752
- </SelectItem>
753
- ))}
754
- </SelectContent>
755
- </Select>
756
- {validationErrors.product && (
757
- <div className="flex items-center gap-1 text-red-600 text-sm">
758
- <AlertCircle className="h-3 w-3" />
759
- {validationErrors.product}
760
- </div>
761
- )}
762
- </div>
763
-
764
- <div className="space-y-3">
765
- <Label
766
- htmlFor="type"
767
- className="flex items-center gap-1 font-medium text-sm"
768
- >
769
- Support Type *
770
- </Label>
771
- <Select value={formData.type} onValueChange={handleTypeChange}>
772
- <SelectTrigger
773
- className={cn(
774
- 'h-11',
775
- validationErrors.type &&
776
- 'border-red-500 focus:border-red-500'
777
- )}
778
- >
779
- <SelectValue placeholder="Select support type..." />
780
- </SelectTrigger>
781
- <SelectContent>
782
- {DEFAULT_SUPPORT_TYPES.map((type) => (
783
- <SelectItem key={type.value} value={type.value}>
784
- {type.label}
785
- </SelectItem>
786
- ))}
787
- </SelectContent>
788
- </Select>
789
- {validationErrors.type && (
790
- <div className="flex items-center gap-1 text-red-600 text-sm">
791
- <AlertCircle className="h-3 w-3" />
792
- {validationErrors.type}
793
- </div>
794
- )}
795
- </div>
796
-
797
- <div className="space-y-3">
798
- <Label
799
- htmlFor="suggestion"
800
- className="flex items-center gap-1 font-medium text-sm"
801
- >
802
- {t('suggestion-improve')}
803
- </Label>
804
- <Textarea
805
- id={suggestionId}
806
- placeholder={t('suggestion-placeholder')}
807
- value={formData.suggestion}
808
- onChange={handleSuggestionChange}
809
- className={cn(
810
- 'max-h-62.5 min-h-30 resize-y',
811
- validationErrors.suggestion &&
812
- 'border-red-500 focus:border-red-500'
813
- )}
814
- />
815
- {validationErrors.suggestion && (
816
- <div className="flex items-center gap-1 text-red-600 text-sm">
817
- <AlertCircle className="h-3 w-3" />
818
- {validationErrors.suggestion}
819
- </div>
820
- )}
821
- <div className="text-muted-foreground text-xs">
822
- {formData.suggestion.length}/1000 characters
823
- </div>
824
- </div>
825
-
826
- <div className="space-y-4">
827
- <Label className="font-medium text-sm">
828
- {t('media-optional')} ({formData.media.length}/{MAX_FILES})
829
- </Label>
830
-
831
- {formData.media.length < MAX_FILES && (
832
- <button
833
- type="button"
834
- className={cn(
835
- 'relative mt-2 w-full rounded-lg border-2 border-dashed transition-all duration-200',
836
- isDragOver
837
- ? 'border-orange-400 bg-orange-50 dark:bg-orange-950/20'
838
- : 'border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
839
- isCompressing && 'pointer-events-none opacity-50'
840
- )}
841
- onDragOver={handleDragOver}
842
- onDragLeave={handleDragLeave}
843
- onDrop={handleDrop}
844
- aria-label="Click to upload or drag and drop media files"
845
- disabled={isCompressing}
846
- >
847
- <Input
848
- ref={fileInputRef}
849
- type="file"
850
- accept="image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm,video/quicktime"
851
- multiple
852
- onChange={handleMediaUpload}
853
- disabled={isSubmitting || isCompressing}
854
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
855
- id={mediaUploadId}
856
- />
857
- <div className="flex flex-col items-center justify-center px-4 py-8 text-center">
858
- <div
859
- className={cn(
860
- 'mb-3 flex h-12 w-12 items-center justify-center rounded-full transition-colors',
861
- isDragOver
862
- ? 'bg-orange-100 dark:bg-orange-900'
863
- : 'bg-gray-100 dark:bg-gray-800'
864
- )}
865
- >
866
- <Upload
867
- className={cn(
868
- 'h-5 w-5',
869
- isDragOver ? 'text-orange-600' : 'text-gray-400'
870
- )}
871
- />
872
- </div>
873
- <p className="mb-1 font-medium text-sm">
874
- {isCompressing
875
- ? 'Compressing...'
876
- : isDragOver
877
- ? 'Drop files here'
878
- : 'Click to upload or drag and drop'}
879
- </p>
880
- <p className="text-muted-foreground text-xs">
881
- Images (PNG, JPG, GIF, WebP) or Videos (MP4, WebM, MOV) up
882
- to 5MB each
883
- </p>
884
- </div>
885
- </button>
886
- )}
887
-
888
- {validationErrors.media && (
889
- <div className="text-red-600 text-sm">
890
- {validationErrors.media}
891
- </div>
892
- )}
893
-
894
- {mediaPreviews.length > 0 && (
895
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
896
- {mediaPreviews.map((preview, index) => {
897
- const file = formData.media[index];
898
- const isVideo =
899
- file && ALLOWED_VIDEO_TYPES.includes(file.type);
900
-
901
- return (
902
- <div key={preview} className="group relative">
903
- <div className="aspect-square overflow-hidden rounded-lg border-2 border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
904
- {isVideo ? (
905
- <video
906
- src={isValidBlobUrl(preview) ? preview : ''}
907
- className="h-full w-full object-cover"
908
- controls={false}
909
- muted
910
- playsInline
911
- />
912
- ) : (
913
- <Image
914
- src={
915
- isValidBlobUrl(preview)
916
- ? preview
917
- : '/placeholder.svg'
918
- }
919
- alt={t('media-alt', { number: index + 1 })}
920
- className="h-full w-full object-cover"
921
- width={100}
922
- height={100}
923
- />
924
- )}
925
- </div>
926
- <Button
927
- type="button"
928
- variant="destructive"
929
- size="icon"
930
- className="absolute -top-2 -right-2 h-6 w-6 rounded-full opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
931
- onClick={() => removeMedia(index)}
932
- >
933
- <X className="h-3 w-3" />
934
- </Button>
935
- </div>
936
- );
937
- })}
938
- </div>
939
- )}
940
- </div>
941
-
942
- <div className="flex w-full flex-col-reverse gap-3 border-t pt-4 sm:flex-row sm:justify-end">
943
- <Button
944
- type="button"
945
- variant="outline"
946
- onClick={() => handleOpenChange(false)}
947
- disabled={isSubmitting}
948
- className="w-full sm:w-auto"
949
- >
950
- {t('cancel')}
951
- </Button>
952
- <Button
953
- type="submit"
954
- disabled={
955
- !formData.product ||
956
- !formData.type ||
957
- !formData.suggestion.trim() ||
958
- isSubmitting
959
- }
960
- className="w-full bg-orange-600 hover:bg-orange-700 disabled:opacity-50 sm:w-auto"
961
- >
962
- {isSubmitting ? (
963
- <div className="flex items-center gap-2">
964
- <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
965
- {t('submitting')}
966
- </div>
967
- ) : (
968
- <div className="flex items-center gap-2">
969
- <CheckCircle className="h-4 w-4" />
970
- {t('submit-report')}
971
- </div>
972
- )}
973
- </Button>
974
- </div>
975
- </form>
976
- </div>
977
- </DialogContent>
978
- </Dialog>
979
- );
10
+ return <ReportProblemDialogContent {...props} t={t} ImageComponent={Image} />;
980
11
  }