@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.
- package/CHANGELOG.md +40 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
- package/src/components/ui/custom/mobile-header-height.ts +16 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/structure.tsx +37 -5
- package/src/components/ui/custom/user-filters.tsx +2 -1
- package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
- package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
- package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
- package/src/components/ui/custom/workspace-select.tsx +95 -359
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
- package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- package/src/components/ui/popover.tsx +3 -1
- package/src/components/ui/resizable.tsx +2 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +8 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +43 -44
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
- package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-write-payload.ts +24 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +38 -72
- package/src/hooks/use-calendar.tsx +21 -45
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-visible-workspaces.ts +57 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-workspace-user.ts +15 -3
- package/src/hooks/use-workspace-visibility.ts +234 -0
- 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 :
|
|
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 :
|
|
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 :
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
253
|
-
|
|
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
|
|
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
|
+
}
|