@tuturuuu/ui 0.30.2 → 0.32.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 +49 -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/chat/ai-message-markdown.tsx +17 -2
- 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
|
@@ -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
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
}
|