@tuturuuu/ui 0.37.1 → 0.38.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 (64) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  6. package/src/components/ui/custom/education-todo.tsx +191 -0
  7. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  8. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  9. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  10. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  11. package/src/components/ui/custom/structure.tsx +37 -5
  12. package/src/components/ui/custom/user-filters.tsx +2 -1
  13. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  14. package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
  15. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  16. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  17. package/src/components/ui/custom/workspace-select.tsx +95 -359
  18. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  19. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  20. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  21. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  22. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  23. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  24. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  25. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  26. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  27. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  28. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  29. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  30. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  31. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  32. package/src/components/ui/popover.tsx +3 -1
  33. package/src/components/ui/resizable.tsx +2 -1
  34. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  35. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  36. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  37. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  38. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  39. package/src/components/ui/text-editor/editor.tsx +8 -3
  40. package/src/components/ui/text-editor/extensions.ts +8 -2
  41. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  42. package/src/components/ui/text-editor/markdown.ts +11 -0
  43. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  44. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  45. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  46. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  47. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  48. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  49. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  52. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  53. package/src/hooks/calendar-creation-request.test.ts +62 -0
  54. package/src/hooks/calendar-creation-request.ts +34 -0
  55. package/src/hooks/calendar-event-write-payload.ts +24 -0
  56. package/src/hooks/notification-subscription.ts +5 -2
  57. package/src/hooks/supabase-provider.ts +38 -72
  58. package/src/hooks/use-calendar.tsx +21 -45
  59. package/src/hooks/use-exchange-rates.ts +4 -15
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-presence.ts +9 -9
  62. package/src/hooks/use-workspace-user.ts +15 -3
  63. package/src/hooks/use-workspace-visibility.ts +234 -0
  64. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -0,0 +1,356 @@
1
+ 'use client';
2
+
3
+ import { ArrowLeft, Eye, EyeOff, Plus, Search, Star, X } from '@tuturuuu/icons';
4
+ import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
5
+ import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
6
+ import { cn } from '@tuturuuu/utils/format';
7
+ import { useTranslations } from 'next-intl';
8
+ import { type ReactNode, useRef, useState } from 'react';
9
+ import type { useWorkspaceVisibility } from '../../../hooks/use-workspace-visibility';
10
+ import { Button } from '../button';
11
+ import {
12
+ DialogClose,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogTitle,
16
+ } from '../dialog';
17
+ import { Input } from '../input';
18
+ import { PopoverClose, PopoverContent } from '../popover';
19
+ import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
20
+ import { WorkspaceIcon } from './workspace-select-icon';
21
+
22
+ type Visibility = ReturnType<typeof useWorkspaceVisibility>;
23
+ export function WorkspacePickerContent({
24
+ workspaces,
25
+ currentId,
26
+ defaultId,
27
+ visibility,
28
+ onSelect,
29
+ onDefault,
30
+ onCreate,
31
+ onJoin,
32
+ invitations,
33
+ restoreOnly = false,
34
+ listLoading = false,
35
+ listError,
36
+ onRetryList,
37
+ presentation = 'fullscreen',
38
+ onClose,
39
+ }: {
40
+ workspaces: InternalApiWorkspaceSummary[];
41
+ currentId?: string;
42
+ defaultId?: string | null;
43
+ visibility: Visibility;
44
+ onSelect: (workspace: InternalApiWorkspaceSummary) => void;
45
+ onDefault?: (id: string) => void;
46
+ onCreate?: () => void;
47
+ onJoin?: () => void;
48
+ invitations?: ReactNode;
49
+ restoreOnly?: boolean;
50
+ listLoading?: boolean;
51
+ listError?: Error | null;
52
+ onRetryList?: () => void;
53
+ presentation?: 'fullscreen' | 'dropdown';
54
+ onClose?: () => void;
55
+ }) {
56
+ const dropdown = presentation === 'dropdown';
57
+ const Title = dropdown ? 'h2' : DialogTitle;
58
+ const Description = dropdown ? 'p' : DialogDescription;
59
+ const t = useTranslations('common');
60
+ const [restoring, setRestoring] = useState(restoreOnly);
61
+ const [searchVisible, setSearchVisible] = useState(dropdown);
62
+ const [search, setSearch] = useState('');
63
+ const input = useRef<HTMLInputElement>(null);
64
+ const query = search.trim().toLocaleLowerCase();
65
+ const choices = workspaces.filter(
66
+ (workspace) => visibility.hiddenIds.includes(workspace.id) === restoring
67
+ );
68
+ const results = choices
69
+ .filter((workspace) =>
70
+ [workspace.name, workspace.id, workspace.tier].some((value) =>
71
+ value?.toLocaleLowerCase().includes(query)
72
+ )
73
+ )
74
+ .sort((a, b) => {
75
+ const rank = (w: InternalApiWorkspaceSummary) =>
76
+ w.id === ROOT_WORKSPACE_ID ? 0 : w.personal ? 1 : 2;
77
+ return (
78
+ rank(a) - rank(b) ||
79
+ (a.name ?? '').localeCompare(b.name ?? '') ||
80
+ a.id.localeCompare(b.id)
81
+ );
82
+ });
83
+ function openSearch() {
84
+ setSearchVisible(true);
85
+ requestAnimationFrame(() => input.current?.focus());
86
+ }
87
+ const content = (
88
+ <>
89
+ <div
90
+ className={cn(
91
+ 'flex items-center gap-3 border-b p-4',
92
+ !dropdown && 'pt-[max(1rem,env(safe-area-inset-top))]'
93
+ )}
94
+ >
95
+ {restoring && !restoreOnly && (
96
+ <Button
97
+ size="icon"
98
+ variant="ghost"
99
+ aria-label={t('back')}
100
+ onClick={() => {
101
+ setRestoring(false);
102
+ setSearch('');
103
+ }}
104
+ >
105
+ <ArrowLeft />
106
+ </Button>
107
+ )}
108
+ <WorkspaceIcon fallbackLogoUrl={TUTURUUU_LOGO_URL} name="Tuturuuu" />
109
+ <Title className="min-w-0 flex-1 font-semibold text-lg leading-none">
110
+ {restoring ? t('hidden_workspaces') : t('workspaces')}
111
+ </Title>
112
+ {dropdown ? (
113
+ <PopoverClose asChild>
114
+ <Button
115
+ size="icon"
116
+ variant="ghost"
117
+ aria-label={t('close')}
118
+ onClick={onClose}
119
+ >
120
+ <X />
121
+ </Button>
122
+ </PopoverClose>
123
+ ) : (
124
+ <DialogClose asChild>
125
+ <Button size="icon" variant="ghost" aria-label={t('close')}>
126
+ <X />
127
+ </Button>
128
+ </DialogClose>
129
+ )}
130
+ </div>
131
+ <Description className={restoring ? 'px-4 pt-3' : 'sr-only'}>
132
+ {restoring ? t('hidden_workspaces_description') : t('select_workspace')}
133
+ </Description>
134
+ {searchVisible && (
135
+ <div className="flex gap-2 px-4 pt-3">
136
+ <Input
137
+ ref={input}
138
+ value={search}
139
+ onChange={(event) => setSearch(event.target.value)}
140
+ placeholder={t('search_workspace')}
141
+ aria-label={t('search_workspace')}
142
+ />
143
+ {query && (
144
+ <Button
145
+ variant="ghost"
146
+ onClick={() => {
147
+ setSearch('');
148
+ input.current?.focus();
149
+ }}
150
+ >
151
+ {t('clear_search')}
152
+ </Button>
153
+ )}
154
+ </div>
155
+ )}
156
+ {(visibility.isError || visibility.updateError || listError) && (
157
+ <div role="alert" className="flex items-center gap-3 px-4 pt-3">
158
+ <p className="flex-1">
159
+ {visibility.updateError
160
+ ? t('hidden_workspaces_update_error')
161
+ : t('hidden_workspaces_load_error')}
162
+ </p>
163
+ <Button
164
+ variant="outline"
165
+ onClick={() => {
166
+ void visibility.refetch();
167
+ onRetryList?.();
168
+ }}
169
+ >
170
+ {t('retry')}
171
+ </Button>
172
+ </div>
173
+ )}
174
+ <div
175
+ className={cn(
176
+ 'min-h-0 flex-1 overflow-y-auto px-4 pt-4',
177
+ dropdown ? 'pb-3' : 'pb-28'
178
+ )}
179
+ >
180
+ {!visibility.known || listLoading || listError ? (
181
+ <p role="status">
182
+ {visibility.isError || listError
183
+ ? t('hidden_workspaces_load_error')
184
+ : t('loading')}
185
+ </p>
186
+ ) : (
187
+ <>
188
+ {!restoring && invitations}
189
+ {results.map((workspace) => (
190
+ <div
191
+ key={workspace.id}
192
+ className={cn(
193
+ 'flex items-center gap-2',
194
+ dropdown ? 'mb-1 rounded-md' : 'mb-2 rounded-xl border p-2'
195
+ )}
196
+ >
197
+ {restoring ? (
198
+ <div className="flex min-w-0 flex-1 items-center gap-3 p-2">
199
+ <WorkspaceIcon
200
+ name={workspace.name ?? ''}
201
+ avatarUrl={workspace.avatar_url ?? undefined}
202
+ />
203
+ <span className="truncate">{workspace.name}</span>
204
+ </div>
205
+ ) : (
206
+ <Button
207
+ variant="ghost"
208
+ className={cn(
209
+ 'h-auto min-w-0 flex-1 justify-start gap-3 whitespace-normal text-start',
210
+ dropdown ? 'py-2' : 'py-3'
211
+ )}
212
+ aria-label={[
213
+ workspace.name ?? workspace.id,
214
+ workspace.tier ?? 'FREE',
215
+ workspace.id === currentId
216
+ ? t('current_workspace')
217
+ : null,
218
+ ]
219
+ .filter(Boolean)
220
+ .join(' ')}
221
+ onClick={() => onSelect(workspace)}
222
+ >
223
+ <WorkspaceIcon
224
+ name={workspace.name ?? ''}
225
+ avatarUrl={workspace.avatar_url ?? undefined}
226
+ />
227
+ <span className="min-w-0 flex-1">
228
+ <span className="block break-words">
229
+ {workspace.name}
230
+ </span>
231
+ <span className="text-muted-foreground text-xs">
232
+ {workspace.tier ?? 'FREE'}
233
+ {workspace.id === currentId
234
+ ? ` · ${t('current_workspace')}`
235
+ : ''}
236
+ {workspace.access_type === 'guest'
237
+ ? ` · ${t('guest_access')}`
238
+ : ''}
239
+ </span>
240
+ </span>
241
+ </Button>
242
+ )}
243
+ {!restoring &&
244
+ workspace.access_type !== 'guest' &&
245
+ onDefault && (
246
+ <Button
247
+ size="icon"
248
+ variant="ghost"
249
+ aria-label={t('default_workspace')}
250
+ disabled={workspace.id === defaultId}
251
+ onClick={() => onDefault(workspace.id)}
252
+ >
253
+ <Star
254
+ className={
255
+ workspace.id === defaultId ? 'fill-current' : ''
256
+ }
257
+ />
258
+ </Button>
259
+ )}
260
+ <Button
261
+ size="icon"
262
+ variant="ghost"
263
+ aria-label={`${t(restoring ? 'restore_workspace' : 'hide_workspace')}: ${workspace.name}`}
264
+ disabled={visibility.pending.has(workspace.id)}
265
+ onClick={() => {
266
+ void visibility
267
+ .setHidden(workspace.id, !restoring)
268
+ .catch(() => {});
269
+ }}
270
+ >
271
+ {restoring ? <Eye /> : <EyeOff />}
272
+ </Button>
273
+ </div>
274
+ ))}
275
+ {!results.length && (
276
+ <p role="status" className="py-8 text-center">
277
+ {query
278
+ ? t('no_workspace_found')
279
+ : restoring
280
+ ? t('hidden_workspaces_empty')
281
+ : workspaces.length
282
+ ? t('all_workspaces_hidden')
283
+ : t('no_workspace_found')}
284
+ </p>
285
+ )}
286
+ </>
287
+ )}
288
+ {!restoring && (
289
+ <Button
290
+ variant="link"
291
+ onClick={() => {
292
+ setRestoring(true);
293
+ setSearch('');
294
+ }}
295
+ >
296
+ {t('hidden_workspaces')}
297
+ </Button>
298
+ )}
299
+ {!restoring && onJoin && (
300
+ <Button variant="link" onClick={onJoin}>
301
+ {t('join_workspace_action')}
302
+ </Button>
303
+ )}
304
+ </div>
305
+ <div
306
+ className={
307
+ dropdown
308
+ ? restoring || !onCreate
309
+ ? 'hidden'
310
+ : 'flex gap-2 border-t p-2'
311
+ : 'absolute right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] flex gap-3 rounded-full border bg-background p-2 shadow-lg'
312
+ }
313
+ >
314
+ {!dropdown && (
315
+ <Button
316
+ size="icon"
317
+ className="rounded-full"
318
+ aria-label={t('search_workspace')}
319
+ onClick={openSearch}
320
+ >
321
+ <Search />
322
+ </Button>
323
+ )}
324
+ {!restoring && onCreate && (
325
+ <Button
326
+ size={dropdown ? 'sm' : 'icon'}
327
+ className={dropdown ? 'w-full justify-start' : 'rounded-full'}
328
+ aria-label={t('create_workspace_action')}
329
+ onClick={onCreate}
330
+ >
331
+ <Plus />
332
+ {dropdown && t('create_workspace_action')}
333
+ </Button>
334
+ )}
335
+ </div>
336
+ </>
337
+ );
338
+ return dropdown ? (
339
+ <PopoverContent
340
+ align="start"
341
+ aria-label={t(restoring ? 'hidden_workspaces' : 'workspaces')}
342
+ collisionPadding={8}
343
+ className="flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-80 max-w-[min(calc(100vw-1rem),var(--radix-popover-content-available-width))] flex-col overflow-hidden p-0"
344
+ >
345
+ {content}
346
+ </PopoverContent>
347
+ ) : (
348
+ <DialogContent
349
+ presentation="fullscreen"
350
+ showCloseButton={false}
351
+ className="flex-col"
352
+ >
353
+ {content}
354
+ </DialogContent>
355
+ );
356
+ }
@@ -93,3 +93,14 @@ export function normalizeWorkspaceSwitchPath(
93
93
 
94
94
  return pathname;
95
95
  }
96
+
97
+ export function resolveGuestWorkspaceLanding(
98
+ workspace: Pick<
99
+ InternalApiWorkspaceSummary,
100
+ 'access_type' | 'guest_landing_path'
101
+ >
102
+ ) {
103
+ return workspace.access_type === 'guest'
104
+ ? workspace.guest_landing_path || '/tasks/boards'
105
+ : null;
106
+ }
@@ -55,6 +55,9 @@ export function useWorkspaceInvitations({
55
55
  await Promise.all([
56
56
  queryClient.invalidateQueries({ queryKey: ['workspace-invitations'] }),
57
57
  queryClient.invalidateQueries({ queryKey: ['workspaces'] }),
58
+ queryClient.invalidateQueries({
59
+ queryKey: ['workspace-ui-list', cacheScope],
60
+ }),
58
61
  queryClient.invalidateQueries({ queryKey: ['user-workspaces'] }),
59
62
  queryClient.invalidateQueries({ queryKey: ['workspace-user'] }),
60
63
  queryClient.invalidateQueries({ queryKey: ['current-user'] }),