@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.
- package/CHANGELOG.md +41 -0
- package/biome.json +1 -1
- package/package.json +95 -49
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- package/src/lib/tasks-app-url.ts +8 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Product, SupportType } from '@tuturuuu/types';
|
|
4
|
+
import { toast } from '@tuturuuu/ui/sonner';
|
|
5
|
+
import imageCompression from 'browser-image-compression';
|
|
6
|
+
import type React from 'react';
|
|
7
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
8
|
+
import {
|
|
9
|
+
ALLOWED_IMAGE_TYPES,
|
|
10
|
+
ALLOWED_VIDEO_TYPES,
|
|
11
|
+
DEFAULT_PRODUCTS,
|
|
12
|
+
LOCAL_STORAGE_KEY,
|
|
13
|
+
MAX_FILES,
|
|
14
|
+
MAX_MEDIA_SIZE,
|
|
15
|
+
type ReportProblemDialogProps,
|
|
16
|
+
type ReportProblemFormData,
|
|
17
|
+
reportProblemSchema,
|
|
18
|
+
} from './report-problem-model';
|
|
19
|
+
import { useSubmitReportMutation } from './report-problem-mutation';
|
|
20
|
+
export function useReportProblem({
|
|
21
|
+
open,
|
|
22
|
+
onOpenChange,
|
|
23
|
+
t,
|
|
24
|
+
apiOptions,
|
|
25
|
+
}: ReportProblemDialogProps) {
|
|
26
|
+
const suggestionId = useId();
|
|
27
|
+
const mediaUploadId = useId();
|
|
28
|
+
|
|
29
|
+
// Internal state for uncontrolled mode
|
|
30
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
31
|
+
|
|
32
|
+
// Use controlled or uncontrolled mode
|
|
33
|
+
const isControlled = open !== undefined;
|
|
34
|
+
const dialogOpen = isControlled ? open : internalOpen;
|
|
35
|
+
|
|
36
|
+
const [formData, setFormData] = useState<ReportProblemFormData>({
|
|
37
|
+
product: '',
|
|
38
|
+
type: '',
|
|
39
|
+
suggestion: '',
|
|
40
|
+
media: [],
|
|
41
|
+
});
|
|
42
|
+
const [mediaPreviews, setMediaPreviews] = useState<string[]>([]);
|
|
43
|
+
const [isCompressing, setIsCompressing] = useState(false);
|
|
44
|
+
const [validationErrors, setValidationErrors] = useState<{
|
|
45
|
+
product?: string;
|
|
46
|
+
type?: string;
|
|
47
|
+
suggestion?: string;
|
|
48
|
+
media?: string;
|
|
49
|
+
}>({});
|
|
50
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
51
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
52
|
+
const mediaPreviewsRef = useRef<string[]>([]);
|
|
53
|
+
const submitReportMutation = useSubmitReportMutation(apiOptions);
|
|
54
|
+
const isSubmitting = submitReportMutation.isPending;
|
|
55
|
+
|
|
56
|
+
const validateForm = () => {
|
|
57
|
+
const result = reportProblemSchema.safeParse(formData);
|
|
58
|
+
if (result.success) {
|
|
59
|
+
setValidationErrors({});
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fieldErrors: {
|
|
64
|
+
product?: string;
|
|
65
|
+
type?: string;
|
|
66
|
+
suggestion?: string;
|
|
67
|
+
media?: string;
|
|
68
|
+
} = {};
|
|
69
|
+
for (const issue of result.error.issues) {
|
|
70
|
+
if (issue.path[0] === 'product' && !fieldErrors.product) {
|
|
71
|
+
fieldErrors.product = issue.message;
|
|
72
|
+
}
|
|
73
|
+
if (issue.path[0] === 'type' && !fieldErrors.type) {
|
|
74
|
+
fieldErrors.type = issue.message;
|
|
75
|
+
}
|
|
76
|
+
if (issue.path[0] === 'suggestion' && !fieldErrors.suggestion) {
|
|
77
|
+
fieldErrors.suggestion = issue.message;
|
|
78
|
+
}
|
|
79
|
+
if (issue.path[0] === 'media' && !fieldErrors.media) {
|
|
80
|
+
fieldErrors.media = issue.message;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
setValidationErrors(fieldErrors);
|
|
84
|
+
return false;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleProductChange = (value: string) => {
|
|
88
|
+
setFormData((prev) => ({ ...prev, product: value as Product }));
|
|
89
|
+
if (validationErrors.product) {
|
|
90
|
+
setValidationErrors((prev) => ({ ...prev, product: undefined }));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const handleTypeChange = (value: string) => {
|
|
95
|
+
setFormData((prev) => ({ ...prev, type: value as SupportType }));
|
|
96
|
+
if (validationErrors.type) {
|
|
97
|
+
setValidationErrors((prev) => ({ ...prev, type: undefined }));
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleSuggestionChange = (
|
|
102
|
+
event: React.ChangeEvent<HTMLTextAreaElement>
|
|
103
|
+
) => {
|
|
104
|
+
setFormData((prev) => ({ ...prev, suggestion: event.target.value }));
|
|
105
|
+
if (validationErrors.suggestion && event.target.value.trim()) {
|
|
106
|
+
setValidationErrors((prev) => ({ ...prev, suggestion: undefined }));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleDragOver = (event: React.DragEvent) => {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
if (event.dataTransfer) {
|
|
113
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
114
|
+
}
|
|
115
|
+
setIsDragOver(true);
|
|
116
|
+
};
|
|
117
|
+
const handleDragLeave = (event: React.DragEvent) => {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
setIsDragOver(false);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleDrop = (event: React.DragEvent) => {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
setIsDragOver(false);
|
|
125
|
+
|
|
126
|
+
const files = Array.from(event.dataTransfer.files).filter(
|
|
127
|
+
(file) =>
|
|
128
|
+
ALLOWED_IMAGE_TYPES.includes(file.type) ||
|
|
129
|
+
ALLOWED_VIDEO_TYPES.includes(file.type)
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (files.length > 0) {
|
|
133
|
+
processMediaFiles(files);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const compressImage = async (file: File): Promise<File> => {
|
|
138
|
+
try {
|
|
139
|
+
const options = {
|
|
140
|
+
maxSizeMB: 5,
|
|
141
|
+
maxWidthOrHeight: 1920,
|
|
142
|
+
useWebWorker: true,
|
|
143
|
+
initialQuality: 0.8,
|
|
144
|
+
};
|
|
145
|
+
const compressedBlob = await imageCompression(file, options);
|
|
146
|
+
|
|
147
|
+
// Convert Blob back to File with original name and type
|
|
148
|
+
const compressedFile = new File([compressedBlob], file.name, {
|
|
149
|
+
type: compressedBlob.type || file.type,
|
|
150
|
+
lastModified: Date.now(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return compressedFile;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error('Image compression failed:', error);
|
|
156
|
+
return file; // Return original if compression fails
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const compressVideo = async (file: File): Promise<File> => {
|
|
161
|
+
try {
|
|
162
|
+
// Create video element to load the file
|
|
163
|
+
const video = document.createElement('video');
|
|
164
|
+
video.preload = 'metadata';
|
|
165
|
+
video.muted = true;
|
|
166
|
+
|
|
167
|
+
const videoUrl = URL.createObjectURL(file);
|
|
168
|
+
video.src = videoUrl;
|
|
169
|
+
|
|
170
|
+
// Wait for video to load metadata
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
video.onloadedmetadata = resolve;
|
|
173
|
+
video.onerror = reject;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Calculate target dimensions (max 1280x720 to reduce file size)
|
|
177
|
+
let targetWidth = video.videoWidth;
|
|
178
|
+
let targetHeight = video.videoHeight;
|
|
179
|
+
const maxWidth = 1280;
|
|
180
|
+
const maxHeight = 720;
|
|
181
|
+
|
|
182
|
+
if (targetWidth > maxWidth || targetHeight > maxHeight) {
|
|
183
|
+
const aspectRatio = targetWidth / targetHeight;
|
|
184
|
+
if (aspectRatio > maxWidth / maxHeight) {
|
|
185
|
+
targetWidth = maxWidth;
|
|
186
|
+
targetHeight = Math.round(maxWidth / aspectRatio);
|
|
187
|
+
} else {
|
|
188
|
+
targetHeight = maxHeight;
|
|
189
|
+
targetWidth = Math.round(maxHeight * aspectRatio);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create canvas for video processing
|
|
194
|
+
const canvas = document.createElement('canvas');
|
|
195
|
+
canvas.width = targetWidth;
|
|
196
|
+
canvas.height = targetHeight;
|
|
197
|
+
const ctx = canvas.getContext('2d');
|
|
198
|
+
|
|
199
|
+
if (!ctx) {
|
|
200
|
+
throw new Error('Could not get canvas context');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Set up MediaRecorder with compression settings
|
|
204
|
+
const stream = canvas.captureStream(30); // 30 FPS
|
|
205
|
+
const mimeType = MediaRecorder.isTypeSupported('video/webm; codecs=vp9')
|
|
206
|
+
? 'video/webm; codecs=vp9'
|
|
207
|
+
: MediaRecorder.isTypeSupported('video/webm; codecs=vp8')
|
|
208
|
+
? 'video/webm; codecs=vp8'
|
|
209
|
+
: 'video/webm';
|
|
210
|
+
|
|
211
|
+
const mediaRecorder = new MediaRecorder(stream, {
|
|
212
|
+
mimeType,
|
|
213
|
+
videoBitsPerSecond: 1000000, // 1 Mbps for good quality with compression
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const chunks: Blob[] = [];
|
|
217
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
218
|
+
if (e.data.size > 0) {
|
|
219
|
+
chunks.push(e.data);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Start recording
|
|
224
|
+
mediaRecorder.start();
|
|
225
|
+
|
|
226
|
+
// Play video and draw frames to canvas
|
|
227
|
+
video.currentTime = 0;
|
|
228
|
+
await video.play();
|
|
229
|
+
|
|
230
|
+
const drawFrame = () => {
|
|
231
|
+
if (video.ended || video.paused) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
ctx.drawImage(video, 0, 0, targetWidth, targetHeight);
|
|
235
|
+
requestAnimationFrame(drawFrame);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
drawFrame();
|
|
239
|
+
|
|
240
|
+
// Wait for video to finish
|
|
241
|
+
await new Promise((resolve) => {
|
|
242
|
+
video.onended = resolve;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Stop recording
|
|
246
|
+
mediaRecorder.stop();
|
|
247
|
+
|
|
248
|
+
// Wait for final data
|
|
249
|
+
const compressedBlob = await new Promise<Blob>((resolve) => {
|
|
250
|
+
mediaRecorder.onstop = () => {
|
|
251
|
+
resolve(new Blob(chunks, { type: mimeType }));
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Clean up
|
|
256
|
+
URL.revokeObjectURL(videoUrl);
|
|
257
|
+
video.remove();
|
|
258
|
+
canvas.remove();
|
|
259
|
+
|
|
260
|
+
// Convert to File
|
|
261
|
+
const fileExtension = mimeType.includes('webm') ? 'webm' : 'mp4';
|
|
262
|
+
const fileName = file.name.replace(/\.[^/.]+$/, `.${fileExtension}`);
|
|
263
|
+
const compressedFile = new File([compressedBlob], fileName, {
|
|
264
|
+
type: mimeType,
|
|
265
|
+
lastModified: Date.now(),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Only return compressed version if it's actually smaller
|
|
269
|
+
return compressedFile.size < file.size ? compressedFile : file;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error('Video compression failed:', error);
|
|
272
|
+
return file; // Return original if compression fails
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const processMediaFiles = async (files: File[]) => {
|
|
277
|
+
// First filter by type only
|
|
278
|
+
const validTypeFiles = files.filter(
|
|
279
|
+
(file) =>
|
|
280
|
+
ALLOWED_IMAGE_TYPES.includes(file.type) ||
|
|
281
|
+
ALLOWED_VIDEO_TYPES.includes(file.type)
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const invalidTypeCount = files.length - validTypeFiles.length;
|
|
285
|
+
if (invalidTypeCount > 0) {
|
|
286
|
+
setValidationErrors((prev) => ({
|
|
287
|
+
...prev,
|
|
288
|
+
media: `${invalidTypeCount} file(s) rejected: only images (PNG, JPEG, WebP, GIF) and videos (MP4, WebM, MOV) are allowed.`,
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const currentMediaCount = formData.media.length;
|
|
293
|
+
const availableSlots = MAX_FILES - currentMediaCount;
|
|
294
|
+
const filesToProcess = validTypeFiles.slice(0, availableSlots);
|
|
295
|
+
const overflow = validTypeFiles.length - filesToProcess.length;
|
|
296
|
+
|
|
297
|
+
if (overflow > 0) {
|
|
298
|
+
setValidationErrors((prev) => ({
|
|
299
|
+
...prev,
|
|
300
|
+
media: `You can upload up to ${MAX_FILES} files. ${overflow} file(s) were rejected.`,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (filesToProcess.length > 0) {
|
|
305
|
+
setIsCompressing(true);
|
|
306
|
+
try {
|
|
307
|
+
// Compress images and videos first, then check size
|
|
308
|
+
const processedWithSize = await Promise.all(
|
|
309
|
+
filesToProcess.map(async (file) => {
|
|
310
|
+
let processedFile = file;
|
|
311
|
+
|
|
312
|
+
// Compress images before size check
|
|
313
|
+
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
|
314
|
+
processedFile = await compressImage(file);
|
|
315
|
+
}
|
|
316
|
+
// Compress videos before size check
|
|
317
|
+
else if (ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
|
318
|
+
processedFile = await compressVideo(file);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
file: processedFile,
|
|
323
|
+
isValid: processedFile.size <= MAX_MEDIA_SIZE,
|
|
324
|
+
originalName: file.name,
|
|
325
|
+
};
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// Filter out files that are still too large after compression
|
|
330
|
+
const validFiles = processedWithSize.filter((item) => item.isValid);
|
|
331
|
+
const rejectedBySize = processedWithSize.filter(
|
|
332
|
+
(item) => !item.isValid
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
if (rejectedBySize.length > 0) {
|
|
336
|
+
const rejectedNames = rejectedBySize
|
|
337
|
+
.map((item) => item.originalName)
|
|
338
|
+
.join(', ');
|
|
339
|
+
setValidationErrors((prev) => ({
|
|
340
|
+
...prev,
|
|
341
|
+
media: `${rejectedBySize.length} file(s) rejected (exceeded 5MB even after compression): ${rejectedNames}`,
|
|
342
|
+
}));
|
|
343
|
+
} else if (validationErrors.media && invalidTypeCount === 0) {
|
|
344
|
+
setValidationErrors((prev) => ({ ...prev, media: undefined }));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (validFiles.length > 0) {
|
|
348
|
+
const acceptedFiles = validFiles.map((item) => item.file);
|
|
349
|
+
const newMedia = [...formData.media, ...acceptedFiles];
|
|
350
|
+
setFormData((prev) => ({ ...prev, media: newMedia }));
|
|
351
|
+
|
|
352
|
+
// Create preview URLs
|
|
353
|
+
const newPreviews = acceptedFiles.map((file) =>
|
|
354
|
+
URL.createObjectURL(file)
|
|
355
|
+
);
|
|
356
|
+
setMediaPreviews((prev) => {
|
|
357
|
+
const updated = [...prev, ...newPreviews];
|
|
358
|
+
mediaPreviewsRef.current = updated;
|
|
359
|
+
return updated;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
} finally {
|
|
363
|
+
setIsCompressing(false);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const handleMediaUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
369
|
+
const files = Array.from(event.target.files || []);
|
|
370
|
+
processMediaFiles(files);
|
|
371
|
+
|
|
372
|
+
// Reset file input
|
|
373
|
+
if (fileInputRef.current) {
|
|
374
|
+
fileInputRef.current.value = '';
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const removeMedia = (index: number) => {
|
|
379
|
+
// Revoke the object URL to free memory
|
|
380
|
+
if (mediaPreviews[index]) {
|
|
381
|
+
URL.revokeObjectURL(mediaPreviews[index]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const newMedia = formData.media.filter((_, i) => i !== index);
|
|
385
|
+
const newPreviews = mediaPreviews.filter((_, i) => i !== index);
|
|
386
|
+
|
|
387
|
+
setFormData((prev) => ({ ...prev, media: newMedia }));
|
|
388
|
+
setMediaPreviews(newPreviews);
|
|
389
|
+
mediaPreviewsRef.current = newPreviews;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const handleSubmit = async (event: React.FormEvent) => {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
|
|
395
|
+
if (!validateForm()) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const validatedForm = reportProblemSchema.parse(formData);
|
|
401
|
+
|
|
402
|
+
// Generate a subject based on the type and product
|
|
403
|
+
const subject = `${validatedForm.type === 'bug' ? 'Bug Report' : 'Feature Request'} - ${
|
|
404
|
+
DEFAULT_PRODUCTS.find((p) => p.value === validatedForm.product)
|
|
405
|
+
?.label || validatedForm.product
|
|
406
|
+
}`;
|
|
407
|
+
|
|
408
|
+
await submitReportMutation.mutateAsync({
|
|
409
|
+
product: validatedForm.product,
|
|
410
|
+
type: validatedForm.type,
|
|
411
|
+
suggestion: validatedForm.suggestion,
|
|
412
|
+
subject,
|
|
413
|
+
media: validatedForm.media,
|
|
414
|
+
});
|
|
415
|
+
toast.success(t('report-submitted-success'));
|
|
416
|
+
|
|
417
|
+
// Close dialog (which will also trigger cleanup)
|
|
418
|
+
handleOpenChange(false);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
console.error('Failed to submit report:', error);
|
|
421
|
+
toast.error('Failed to submit report. Please try again.');
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const handleOpenChange = (newOpen: boolean) => {
|
|
426
|
+
// Update internal state for uncontrolled mode
|
|
427
|
+
if (!isControlled) {
|
|
428
|
+
setInternalOpen(newOpen);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Notify parent if handler exists (controlled mode)
|
|
432
|
+
if (onOpenChange) {
|
|
433
|
+
onOpenChange(newOpen);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Clean up and reset everything when dialog closes
|
|
437
|
+
if (!newOpen) {
|
|
438
|
+
// Revoke all object URLs to free memory
|
|
439
|
+
mediaPreviews.forEach((url) => {
|
|
440
|
+
URL.revokeObjectURL(url);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Clear all form state
|
|
444
|
+
setFormData({ product: '', type: '', suggestion: '', media: [] });
|
|
445
|
+
setMediaPreviews([]);
|
|
446
|
+
mediaPreviewsRef.current = [];
|
|
447
|
+
setValidationErrors({});
|
|
448
|
+
setIsDragOver(false);
|
|
449
|
+
setIsCompressing(false);
|
|
450
|
+
|
|
451
|
+
// Clear localStorage
|
|
452
|
+
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Clean up object URLs on unmount
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
return () => {
|
|
459
|
+
mediaPreviewsRef.current.forEach((url) => {
|
|
460
|
+
URL.revokeObjectURL(url);
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
}, []);
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
dialogOpen,
|
|
467
|
+
handleOpenChange,
|
|
468
|
+
handleSubmit,
|
|
469
|
+
formData,
|
|
470
|
+
setFormData,
|
|
471
|
+
validationErrors,
|
|
472
|
+
isSubmitting,
|
|
473
|
+
isCompressing,
|
|
474
|
+
suggestionId,
|
|
475
|
+
mediaUploadId,
|
|
476
|
+
isDragOver,
|
|
477
|
+
fileInputRef,
|
|
478
|
+
handleDragOver,
|
|
479
|
+
handleDragLeave,
|
|
480
|
+
handleDrop,
|
|
481
|
+
handleProductChange,
|
|
482
|
+
handleTypeChange,
|
|
483
|
+
handleSuggestionChange,
|
|
484
|
+
handleMediaUpload,
|
|
485
|
+
mediaPreviews,
|
|
486
|
+
removeMedia,
|
|
487
|
+
};
|
|
488
|
+
}
|
package/src/declarations.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import type { Workspace } from '@tuturuuu/types';
|
|
2
3
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
3
4
|
import type { ReactNode } from 'react';
|
|
4
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -16,6 +17,8 @@ const internalApiMocks = vi.hoisted(() => ({
|
|
|
16
17
|
updateWorkspaceCalendarEvent: vi.fn(),
|
|
17
18
|
}));
|
|
18
19
|
|
|
20
|
+
const workspace = { id: 'workspace-1', name: 'Workspace' } as Workspace;
|
|
21
|
+
|
|
19
22
|
vi.mock('@tuturuuu/internal-api', () => ({
|
|
20
23
|
createWorkspaceCalendarEvent: internalApiMocks.createWorkspaceCalendarEvent,
|
|
21
24
|
deleteWorkspaceCalendarEvent: internalApiMocks.deleteWorkspaceCalendarEvent,
|
|
@@ -66,7 +69,7 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
66
69
|
function Wrapper({ children }: { children: ReactNode }) {
|
|
67
70
|
return (
|
|
68
71
|
<CalendarProvider
|
|
69
|
-
ws={
|
|
72
|
+
ws={workspace}
|
|
70
73
|
useQuery={mockUseQuery}
|
|
71
74
|
useQueryClient={mockUseQueryClient}
|
|
72
75
|
readOnly={readOnly}
|
|
@@ -84,6 +87,45 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
84
87
|
ws_id: 'workspace-1',
|
|
85
88
|
};
|
|
86
89
|
|
|
90
|
+
it('opens a deep-linked event after it is loaded', async () => {
|
|
91
|
+
calendarMockState.events = [baseEvent];
|
|
92
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
93
|
+
<CalendarProvider
|
|
94
|
+
initialEventId="event-1"
|
|
95
|
+
useQuery={mockUseQuery}
|
|
96
|
+
useQueryClient={mockUseQueryClient}
|
|
97
|
+
ws={workspace}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</CalendarProvider>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const { result } = renderHook(() => useCalendar(), { wrapper });
|
|
104
|
+
await act(async () => undefined);
|
|
105
|
+
expect(result.current.activeEvent?.id).toBe('event-1');
|
|
106
|
+
expect(result.current.isModalOpen).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('routes a deep-linked event through a custom event adapter', async () => {
|
|
110
|
+
calendarMockState.events = [baseEvent];
|
|
111
|
+
const onOpen = vi.fn();
|
|
112
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
113
|
+
<CalendarProvider
|
|
114
|
+
eventAdapter={{ disableBuiltInEventUi: true, onOpen }}
|
|
115
|
+
initialEventId="event-1"
|
|
116
|
+
useQuery={mockUseQuery}
|
|
117
|
+
useQueryClient={mockUseQueryClient}
|
|
118
|
+
ws={workspace}
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
</CalendarProvider>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
renderHook(() => useCalendar(), { wrapper });
|
|
125
|
+
await act(async () => undefined);
|
|
126
|
+
expect(onOpen).toHaveBeenCalledWith('event-1', baseEvent);
|
|
127
|
+
});
|
|
128
|
+
|
|
87
129
|
it('should have readOnly set to true when passed as prop', () => {
|
|
88
130
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
89
131
|
<CalendarProvider
|
|
@@ -115,7 +157,7 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
115
157
|
title: 'Test',
|
|
116
158
|
start_at: new Date().toISOString(),
|
|
117
159
|
end_at: new Date().toISOString(),
|
|
118
|
-
}
|
|
160
|
+
});
|
|
119
161
|
expect(event).toBeUndefined();
|
|
120
162
|
});
|
|
121
163
|
|
|
@@ -165,7 +207,7 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
165
207
|
|
|
166
208
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
167
209
|
<CalendarProvider
|
|
168
|
-
ws={
|
|
210
|
+
ws={workspace}
|
|
169
211
|
useQuery={mockUseQuery}
|
|
170
212
|
useQueryClient={mockUseQueryClient}
|
|
171
213
|
>
|
|
@@ -195,7 +237,7 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
195
237
|
it('opens the editor directly for create flows', () => {
|
|
196
238
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
197
239
|
<CalendarProvider
|
|
198
|
-
ws={
|
|
240
|
+
ws={workspace}
|
|
199
241
|
useQuery={mockUseQuery}
|
|
200
242
|
useQueryClient={mockUseQueryClient}
|
|
201
243
|
>
|
|
@@ -154,6 +154,30 @@ describe('CalendarSyncProvider optimistic visible events', () => {
|
|
|
154
154
|
vi.stubGlobal('fetch', vi.fn());
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
it('reports a partial HTTP 200 sync as an error without a success callback', async () => {
|
|
158
|
+
vi.stubGlobal(
|
|
159
|
+
'fetch',
|
|
160
|
+
vi.fn(async () =>
|
|
161
|
+
Response.json({
|
|
162
|
+
ok: false,
|
|
163
|
+
partialFailure: true,
|
|
164
|
+
error: 'One calendar failed',
|
|
165
|
+
})
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
const { result } = renderCalendarSync({
|
|
169
|
+
externalEvents: [createEvent('saved')],
|
|
170
|
+
});
|
|
171
|
+
const progress = vi.fn();
|
|
172
|
+
await act(async () => {
|
|
173
|
+
await result.current.syncToTuturuuu(progress, { skipCooldown: true });
|
|
174
|
+
});
|
|
175
|
+
expect(result.current.syncStatus.state).toBe('error');
|
|
176
|
+
expect(result.current.error?.message).toBe('One calendar failed');
|
|
177
|
+
expect(progress).not.toHaveBeenCalled();
|
|
178
|
+
expect(result.current.events[0]?.id).toBe('saved');
|
|
179
|
+
});
|
|
180
|
+
|
|
157
181
|
it('keeps last successful events visible while a refetch is pending', async () => {
|
|
158
182
|
const initialEvent = createEvent('event-1');
|
|
159
183
|
let databaseCalls = 0;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function roundToNearest15Minutes(date: Date): Date {
|
|
2
|
+
const minutes = date.getMinutes();
|
|
3
|
+
const remainder = minutes % 15;
|
|
4
|
+
const roundedMinutes =
|
|
5
|
+
remainder < 8 ? minutes - remainder : minutes + (15 - remainder);
|
|
6
|
+
const roundedDate = new Date(date);
|
|
7
|
+
roundedDate.setMinutes(roundedMinutes);
|
|
8
|
+
roundedDate.setSeconds(0);
|
|
9
|
+
roundedDate.setMilliseconds(0);
|
|
10
|
+
return roundedDate;
|
|
11
|
+
}
|