@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,191 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import {
5
+ BookOpen,
6
+ CalendarCheck,
7
+ CheckCheck,
8
+ ClipboardList,
9
+ } from '@tuturuuu/icons';
10
+ import {
11
+ type EducationTodoItem,
12
+ type EducationTodoKind,
13
+ listEducationTodo,
14
+ } from '@tuturuuu/internal-api/teach';
15
+ import { Button } from '@tuturuuu/ui/button';
16
+ import { Checkbox } from '@tuturuuu/ui/checkbox';
17
+ import Link from 'next/link';
18
+ import { useState } from 'react';
19
+
20
+ const kinds = ['tutoring', 'assignments', 'lessons', 'tests'] as const;
21
+ const icons = {
22
+ tutoring: CalendarCheck,
23
+ assignments: ClipboardList,
24
+ lessons: BookOpen,
25
+ tests: ClipboardList,
26
+ };
27
+ function itemHref(
28
+ app: 'learn' | 'teach',
29
+ wsId: string,
30
+ kind: EducationTodoKind,
31
+ row: EducationTodoItem
32
+ ) {
33
+ if (kind === 'tutoring' || !row.courseId) return null;
34
+ if (kind === 'tests')
35
+ return `/${wsId}/courses/${row.courseId}${app === 'learn' ? `/tests/${row.id}` : ''}`;
36
+ if (kind === 'assignments')
37
+ return `/${wsId}/assignments${app === 'teach' ? `?course=${row.courseId}` : ''}`;
38
+ return `/${wsId}/courses/${row.courseId}${app === 'teach' ? `/${row.id}` : ''}`;
39
+ }
40
+
41
+ export function EducationTodo({
42
+ app,
43
+ wsId,
44
+ actorId,
45
+ t,
46
+ }: {
47
+ app: 'learn' | 'teach';
48
+ wsId: string;
49
+ actorId: string;
50
+ t: (key: string, values?: Record<string, string | number>) => string;
51
+ }) {
52
+ const [kind, setKind] = useState<EducationTodoKind>('tutoring');
53
+ const [page, setPage] = useState(1);
54
+ const [showCompleted, setShowCompleted] = useState(false);
55
+ const query = useQuery({
56
+ queryKey: ['education-todo', app, wsId, actorId, kind, page],
57
+ queryFn: () => listEducationTodo(app, wsId, { kind, page }),
58
+ });
59
+ const rows = (query.data?.data ?? []).filter(
60
+ (row) => showCompleted || !row.completed
61
+ );
62
+ return (
63
+ <main className="mx-auto max-w-5xl space-y-5 p-5 md:p-8">
64
+ <header className="space-y-2">
65
+ <h1 className="font-semibold text-3xl">{t('title')}</h1>
66
+ <p className="max-w-2xl text-muted-foreground">
67
+ {t(app === 'learn' ? 'learnDescription' : 'teachDescription')}
68
+ </p>
69
+ </header>
70
+ <nav aria-label={t('categories')} className="flex flex-wrap gap-2">
71
+ {kinds.map((value) => {
72
+ const Icon = icons[value];
73
+ return (
74
+ <Button
75
+ key={value}
76
+ variant={kind === value ? 'default' : 'outline'}
77
+ aria-pressed={kind === value}
78
+ onClick={() => {
79
+ setKind(value);
80
+ setPage(1);
81
+ }}
82
+ >
83
+ <Icon className="size-4" />
84
+ {t(value)}
85
+ </Button>
86
+ );
87
+ })}
88
+ </nav>
89
+ {kind !== 'tutoring' && app === 'learn' ? (
90
+ <label className="flex items-center gap-2 text-sm">
91
+ <Checkbox
92
+ checked={showCompleted}
93
+ onCheckedChange={(value) => setShowCompleted(value === true)}
94
+ />
95
+ {t('showCompleted')}
96
+ </label>
97
+ ) : null}
98
+ {query.isError ? (
99
+ <div role="alert" className="space-y-3 rounded-xl border p-5">
100
+ <p>{t('error')}</p>
101
+ <Button onClick={() => void query.refetch()} variant="outline">
102
+ {t('retry')}
103
+ </Button>
104
+ </div>
105
+ ) : query.isPending ? (
106
+ <p role="status">{t('loading')}</p>
107
+ ) : (
108
+ <>
109
+ <p className="text-muted-foreground text-sm">
110
+ {t(app === 'learn' ? 'assignedCount' : 'pendingCount', {
111
+ count: query.data?.count ?? 0,
112
+ })}
113
+ </p>
114
+ <ul className="space-y-3">
115
+ {rows.map((row) => {
116
+ const href = itemHref(app, wsId, kind, row);
117
+ return (
118
+ <li
119
+ key={row.id}
120
+ className="flex flex-wrap items-center justify-between gap-4 rounded-xl border bg-card p-4"
121
+ >
122
+ <div className="min-w-0 space-y-1">
123
+ <h2 className="line-clamp-3 break-words font-medium">
124
+ {row.title ||
125
+ t(kind === 'tutoring' ? 'tutoring' : 'untitled')}
126
+ </h2>
127
+ {row.participantName ? (
128
+ <p className="text-muted-foreground text-sm">
129
+ {t(app === 'teach' ? 'student' : 'teacher')}:{' '}
130
+ {row.participantName}
131
+ </p>
132
+ ) : null}
133
+ {row.date ? (
134
+ <p className="text-muted-foreground text-sm">
135
+ {row.date}
136
+ {row.startTime ? ` · ${row.startTime.slice(0, 5)}` : ''}
137
+ {row.durationMinutes
138
+ ? ` · ${t('minutes', { count: row.durationMinutes })}`
139
+ : ''}
140
+ </p>
141
+ ) : null}
142
+ {row.completed ? (
143
+ <p className="flex items-center gap-1 text-muted-foreground text-sm">
144
+ <CheckCheck className="size-4" />
145
+ {t('completed')}
146
+ </p>
147
+ ) : null}
148
+ </div>
149
+ {href ? (
150
+ <Button asChild size="sm" variant="outline">
151
+ <Link href={href}>{t('open')}</Link>
152
+ </Button>
153
+ ) : null}
154
+ </li>
155
+ );
156
+ })}
157
+ </ul>
158
+ {!rows.length ? (
159
+ <p className="rounded-xl border border-dashed p-6 text-muted-foreground">
160
+ {t((query.data?.count ?? 0) ? 'emptyPage' : 'empty')}
161
+ </p>
162
+ ) : null}
163
+ {(query.data?.totalPages ?? 0) > 1 ? (
164
+ <nav
165
+ aria-label={t('pages')}
166
+ className="flex items-center justify-between gap-3"
167
+ >
168
+ <Button
169
+ disabled={page === 1}
170
+ onClick={() => setPage((value) => value - 1)}
171
+ variant="outline"
172
+ >
173
+ {t('previous')}
174
+ </Button>
175
+ <span className="text-sm">
176
+ {t('page', { page, total: query.data?.totalPages ?? 0 })}
177
+ </span>
178
+ <Button
179
+ disabled={page >= (query.data?.totalPages ?? 0)}
180
+ onClick={() => setPage((value) => value + 1)}
181
+ variant="outline"
182
+ >
183
+ {t('next')}
184
+ </Button>
185
+ </nav>
186
+ ) : null}
187
+ </>
188
+ )}
189
+ </main>
190
+ );
191
+ }
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { listWorkspaces } from '@tuturuuu/internal-api/workspaces';
5
+ import { useTranslations } from 'next-intl';
6
+ import {
7
+ useWorkspaceActor,
8
+ useWorkspaceVisibility,
9
+ } from '../../../hooks/use-workspace-visibility';
10
+ import { Button } from '../button';
11
+ import { Dialog, DialogTrigger } from '../dialog';
12
+ import { WorkspacePickerContent } from './workspace-picker-content';
13
+
14
+ /** Private Settings restore editor; restoring never changes current/default scope. */
15
+ export function HiddenWorkspacesSettings() {
16
+ const t = useTranslations('common');
17
+ const actor = useWorkspaceActor();
18
+ const visibility = useWorkspaceVisibility();
19
+ const list = useQuery({
20
+ queryKey: ['workspace-ui-list', actor?.actorId],
21
+ enabled: Boolean(actor),
22
+ queryFn: async () => {
23
+ actor!.assertActive();
24
+ const result = await listWorkspaces();
25
+ actor!.assertActive();
26
+ return result;
27
+ },
28
+ });
29
+ return (
30
+ <Dialog>
31
+ <DialogTrigger asChild>
32
+ <Button variant="outline" disabled={!actor}>
33
+ {t('hidden_workspaces')}
34
+ </Button>
35
+ </DialogTrigger>
36
+ <WorkspacePickerContent
37
+ listLoading={list.isLoading}
38
+ listError={list.error}
39
+ onRetryList={() => {
40
+ void list.refetch();
41
+ }}
42
+ restoreOnly
43
+ workspaces={list.data ?? []}
44
+ visibility={visibility}
45
+ onSelect={() => {}}
46
+ />
47
+ </Dialog>
48
+ );
49
+ }
@@ -0,0 +1,16 @@
1
+ /** Measure the fixed mobile header including safe-area padding and wrapped rows. */
2
+ export function observeMobileHeaderHeight(nav: HTMLElement, main: HTMLElement) {
3
+ const update = () => {
4
+ main.style.setProperty(
5
+ '--mobile-nav-height',
6
+ `${nav.getBoundingClientRect().height}px`
7
+ );
8
+ };
9
+ update();
10
+ const observer = new ResizeObserver(update);
11
+ observer.observe(nav);
12
+ return () => {
13
+ observer.disconnect();
14
+ main.style.removeProperty('--mobile-nav-height');
15
+ };
16
+ }
@@ -0,0 +1,61 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { cleanup, render } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { NotificationRuntime } from './notification-runtime';
5
+ import SatelliteNotificationPopover from './satellite-notification-popover';
6
+
7
+ const { unread, list } = vi.hoisted(() => ({ unread: vi.fn(), list: vi.fn() }));
8
+ vi.mock('@tuturuuu/ui/hooks/use-notifications', () => ({
9
+ useUnreadCount: unread,
10
+ useInfiniteNotifications: list,
11
+ useNotificationSubscription: vi.fn(),
12
+ useMarkAllAsRead: () => ({ mutate: vi.fn(), isPending: false }),
13
+ useUpdateNotification: () => ({ mutate: vi.fn(), isPending: false }),
14
+ dedupeNotifications: () => [],
15
+ }));
16
+ vi.mock('./notification-list', () => ({ NotificationList: () => null }));
17
+ describe('satellite freshness policy', () => {
18
+ afterEach(() => {
19
+ cleanup();
20
+ vi.clearAllMocks();
21
+ });
22
+ it.each([
23
+ [undefined, 120_000],
24
+ [30_000, 30_000],
25
+ ])(
26
+ 'uses the default or an explicit app polling interval',
27
+ (pollIntervalMs, expected) => {
28
+ unread.mockReturnValue({ data: 0 });
29
+ list.mockReturnValue({ data: { pages: [] }, isLoading: false });
30
+ render(
31
+ <QueryClientProvider client={new QueryClient()}>
32
+ <NotificationRuntime
33
+ value={{
34
+ params: { wsId: 'personal' },
35
+ pathname: '/',
36
+ router: { push: vi.fn(), refresh: vi.fn() },
37
+ Link: ({ children }) => <span>{children}</span>,
38
+ realtime: false,
39
+ }}
40
+ >
41
+ <SatelliteNotificationPopover
42
+ userId="actor"
43
+ pollIntervalMs={pollIntervalMs}
44
+ noNotificationsText="Empty"
45
+ notificationsText="Notifications"
46
+ viewAllText="View all"
47
+ markAsReadText="Read"
48
+ markAsUnreadText="Unread"
49
+ />
50
+ </NotificationRuntime>
51
+ </QueryClientProvider>
52
+ );
53
+ expect(unread.mock.calls[0]?.[1].refetchInterval).toBe(expected);
54
+ expect(
55
+ list.mock.calls.every(
56
+ ([options]) => options.refetchInterval === expected
57
+ )
58
+ ).toBe(true);
59
+ }
60
+ );
61
+ });
@@ -22,10 +22,13 @@ import { NotificationPopoverTriggerButton } from './notification-popover-trigger
22
22
  import { useNotificationRuntime } from './notification-runtime';
23
23
 
24
24
  dayjs.extend(relativeTime);
25
+ const NOTIFICATION_POLL_INTERVAL_MS = 2 * 60_000;
25
26
  type TabType = 'inbox' | 'archive';
26
27
 
27
28
  export interface NotificationPopoverClientProps {
28
29
  userId?: string;
30
+ /** Optional app-specific freshness policy for non-subscription consumers. */
31
+ pollIntervalMs?: number;
29
32
  noNotificationsText: string;
30
33
  notificationsText: string;
31
34
  viewAllText: string;
@@ -63,6 +66,7 @@ function isValidWorkspaceFilterId(value: unknown): value is string {
63
66
 
64
67
  export default function SatelliteNotificationPopover({
65
68
  userId,
69
+ pollIntervalMs = NOTIFICATION_POLL_INTERVAL_MS,
66
70
  noNotificationsText,
67
71
  notificationsText,
68
72
  viewAllText,
@@ -98,7 +102,7 @@ export default function SatelliteNotificationPopover({
98
102
  const { data: unreadCount = 0 } = useUnreadCount(wsIdForFiltering, {
99
103
  cacheScope: userId,
100
104
  enabled: Boolean(userId),
101
- refetchInterval: realtime ? undefined : 30_000,
105
+ refetchInterval: realtime ? undefined : pollIntervalMs,
102
106
  });
103
107
 
104
108
  // Infinite scroll for inbox (unread) and archive (read)
@@ -107,7 +111,7 @@ export default function SatelliteNotificationPopover({
107
111
  wsId: wsIdForFiltering,
108
112
  unreadOnly: true,
109
113
  pageSize: 15,
110
- refetchInterval: realtime ? undefined : 30_000,
114
+ refetchInterval: realtime ? undefined : pollIntervalMs,
111
115
  enabled: Boolean(userId) && open && activeTab === 'inbox',
112
116
  });
113
117
 
@@ -116,7 +120,7 @@ export default function SatelliteNotificationPopover({
116
120
  wsId: wsIdForFiltering,
117
121
  readOnly: true,
118
122
  pageSize: 15,
119
- refetchInterval: realtime ? undefined : 30_000,
123
+ refetchInterval: realtime ? undefined : pollIntervalMs,
120
124
  enabled: Boolean(userId) && open && activeTab === 'archive',
121
125
  });
122
126
 
@@ -4,7 +4,14 @@ import { ChevronLeft, ChevronRight, Menu, X } from '@tuturuuu/icons';
4
4
  import { Button } from '@tuturuuu/ui/button';
5
5
  import { TooltipProvider } from '@tuturuuu/ui/tooltip';
6
6
  import { cn } from '@tuturuuu/utils/format';
7
- import { type CSSProperties, type ReactNode, useEffect } from 'react';
7
+ import {
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ useEffect,
11
+ useLayoutEffect,
12
+ useRef,
13
+ } from 'react';
14
+ import { observeMobileHeaderHeight } from './mobile-header-height';
8
15
 
9
16
  interface StructureProps {
10
17
  sidebarLabels?: { open: string; close: string };
@@ -22,6 +29,7 @@ interface StructureProps {
22
29
  sidebarUtility?: ReactNode;
23
30
  feedbackButton?: ReactNode;
24
31
  children: ReactNode;
32
+ contentFullBleed?: boolean;
25
33
  onMouseEnter?: () => void;
26
34
  onMouseLeave?: () => void;
27
35
  sidebarHidden?: boolean;
@@ -47,6 +55,7 @@ export function Structure({
47
55
  sidebarUtility,
48
56
  feedbackButton,
49
57
  children,
58
+ contentFullBleed = false,
50
59
  onMouseEnter,
51
60
  onMouseLeave,
52
61
  sidebarHidden = false,
@@ -55,6 +64,15 @@ export function Structure({
55
64
  sidebarHeaderClassName,
56
65
  sidebarHeaderHeight,
57
66
  }: StructureProps) {
67
+ const mobileNavRef = useRef<HTMLElement>(null);
68
+ const mainRef = useRef<HTMLElement>(null);
69
+ useLayoutEffect(() => {
70
+ if (!contentFullBleed || sidebarHidden) return;
71
+ const nav = mobileNavRef.current;
72
+ const main = mainRef.current;
73
+ if (nav && main) return observeMobileHeaderHeight(nav, main);
74
+ }, [contentFullBleed, sidebarHidden]);
75
+
58
76
  const sidebarStyle = {
59
77
  '--sidebar-collapsed-width': sidebarCollapsedWidth,
60
78
  '--sidebar-expanded-width': sidebarExpandedWidth,
@@ -86,7 +104,10 @@ export function Structure({
86
104
  return (
87
105
  <>
88
106
  {!sidebarHidden && (
89
- <nav className="safe-top safe-x fixed inset-x-0 top-0 z-30 max-sm:border-b md:hidden">
107
+ <nav
108
+ ref={mobileNavRef}
109
+ className="safe-top safe-x fixed inset-x-0 top-0 z-30 max-sm:border-b md:hidden"
110
+ >
90
111
  <div className="bg-background/50 p-2 font-semibold backdrop-blur-md md:px-8 lg:px-16 xl:px-32">
91
112
  <div className="relative flex items-center justify-between gap-2 md:gap-4">
92
113
  <div className="flex w-full items-center gap-2">
@@ -236,8 +257,12 @@ export function Structure({
236
257
  {/* Main content area - overflow-y-auto removed to prevent double scrollbars */}
237
258
  {/* Body element now handles page-level scrolling */}
238
259
  <main
260
+ ref={mainRef}
239
261
  className={cn(
240
- 'relative flex h-full min-h-screen flex-col overflow-x-clip overflow-y-visible transition-all duration-300 ease-in-out',
262
+ 'relative flex flex-col overflow-x-clip transition-all duration-300 ease-in-out',
263
+ contentFullBleed
264
+ ? 'h-dvh min-h-0 overflow-y-hidden'
265
+ : 'h-full min-h-screen overflow-y-visible',
241
266
  sidebarHidden
242
267
  ? 'md:pl-0'
243
268
  : isCollapsed || overlayOnExpand
@@ -249,8 +274,15 @@ export function Structure({
249
274
  {header && <div className="mb-4 hidden md:block">{header}</div>}
250
275
  <div
251
276
  className={cn(
252
- 'safe-bottom relative h-full w-full p-2 md:p-4 md:pt-4',
253
- sidebarHidden ? 'pt-2' : 'pt-17 pl-2'
277
+ 'safe-bottom relative w-full',
278
+ contentFullBleed
279
+ ? sidebarHidden
280
+ ? 'flex min-h-0 flex-1 flex-col'
281
+ : 'flex min-h-0 flex-1 flex-col pt-[var(--mobile-nav-height,calc(4.25rem+env(safe-area-inset-top,0px)))] md:pt-0'
282
+ : [
283
+ 'h-full p-2 md:p-4 md:pt-4',
284
+ sidebarHidden ? 'pt-2' : 'pt-17 pl-2',
285
+ ]
254
286
  )}
255
287
  >
256
288
  {children}
@@ -175,7 +175,8 @@ export function Filter({
175
175
  className={cn('h-8 border-dashed px-1', className)}
176
176
  disabled={disabled}
177
177
  >
178
- {selectedSize > 0 ? null : (
178
+ {selectedSize > 0 &&
179
+ options.some((option) => selectedValues.has(option.value)) ? null : (
179
180
  <>
180
181
  {icon} {title}
181
182
  </>
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+ import type { ComponentProps } from 'react';
5
+ import { useWorkspaceVisibility } from '../../../hooks/use-workspace-visibility';
6
+ import { Button } from '../button';
7
+ import { Filter } from './user-filters';
8
+
9
+ /** Filter choices only; never change the underlying authorized report/data query. */
10
+ export function VisibleWorkspaceFilter(props: ComponentProps<typeof Filter>) {
11
+ const t = useTranslations('common');
12
+ const visibility = useWorkspaceVisibility();
13
+ return (
14
+ <>
15
+ <Filter
16
+ {...props}
17
+ options={
18
+ visibility.known
19
+ ? props.options?.filter(
20
+ (option) => !visibility.hiddenIds.includes(option.value)
21
+ )
22
+ : []
23
+ }
24
+ />
25
+ {!visibility.known && visibility.isError && (
26
+ <span role="alert">
27
+ {t('hidden_workspaces_load_error')}
28
+ <Button
29
+ size="sm"
30
+ variant="ghost"
31
+ onClick={() => {
32
+ void visibility.refetch();
33
+ }}
34
+ >
35
+ {t('retry')}
36
+ </Button>
37
+ </span>
38
+ )}
39
+ </>
40
+ );
41
+ }