@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.57
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/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/.prettierignore +20 -0
- package/.vscode/mcp.json +8 -0
- package/AGENTS.md +432 -0
- package/CLAUDE.md +9 -0
- package/Dockerfile +132 -0
- package/Dockerfile.dockerignore +45 -0
- package/README.MD +481 -0
- package/cli/plugins.ts +14 -0
- package/client/agent-annotations-host.ts +19 -0
- package/client/app.ts +38 -0
- package/client/components/back-button.tsx +49 -0
- package/client/components/breadcrumbs.tsx +167 -0
- package/client/components/inbox-badge.ts +49 -0
- package/client/components/inbox-button.tsx +132 -0
- package/client/components/inbox-header-button.tsx +25 -0
- package/client/components/loading.tsx +35 -0
- package/client/components/page-container.tsx +24 -0
- package/client/components/page-header.tsx +33 -0
- package/client/components/route-child-page.tsx +121 -0
- package/client/components/route-dialog.tsx +10 -0
- package/client/components/route-drawer.tsx +10 -0
- package/client/components/route-overlay.tsx +190 -0
- package/client/components/ui/alert.tsx +75 -0
- package/client/components/ui/badge.tsx +51 -0
- package/client/components/ui/breadcrumb.tsx +121 -0
- package/client/components/ui/button.tsx +58 -0
- package/client/components/ui/collapsible.tsx +19 -0
- package/client/components/ui/context-menu.tsx +271 -0
- package/client/components/ui/dialog.tsx +160 -0
- package/client/components/ui/dropdown-menu.tsx +270 -0
- package/client/components/ui/empty.tsx +103 -0
- package/client/components/ui/field.tsx +236 -0
- package/client/components/ui/input-group.tsx +158 -0
- package/client/components/ui/input.tsx +20 -0
- package/client/components/ui/kbd.tsx +26 -0
- package/client/components/ui/label.tsx +18 -0
- package/client/components/ui/popover.tsx +89 -0
- package/client/components/ui/select.tsx +200 -0
- package/client/components/ui/separator.tsx +22 -0
- package/client/components/ui/sheet.tsx +137 -0
- package/client/components/ui/sidebar.tsx +723 -0
- package/client/components/ui/skeleton.tsx +13 -0
- package/client/components/ui/spinner.tsx +20 -0
- package/client/components/ui/tabs.tsx +79 -0
- package/client/components/ui/textarea.tsx +17 -0
- package/client/components/ui/toast.tsx +231 -0
- package/client/components/ui/tooltip.tsx +63 -0
- package/client/components/use-route-overlay.ts +26 -0
- package/client/config/api.ts +13 -0
- package/client/config/app.ts +8 -0
- package/client/config/auth.ts +18 -0
- package/client/config/index.ts +17 -0
- package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
- package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
- package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
- package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
- package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
- package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
- package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
- package/client/extensions/nocobase-file-component-ui/README.md +13 -0
- package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
- package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
- package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
- package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
- package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
- package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
- package/client/extensions/nocobase-inbox/frame.ts +31 -0
- package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
- package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
- package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
- package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
- package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
- package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
- package/client/extensions/nocobase-inbox/model.ts +428 -0
- package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
- package/client/extensions/nocobase-inbox/registry.ts +303 -0
- package/client/extensions/nocobase-inbox/source.ts +117 -0
- package/client/hooks/use-mobile.ts +21 -0
- package/client/index.tsx +60 -0
- package/client/layouts/app-layout.tsx +105 -0
- package/client/layouts/components/app-brand.tsx +49 -0
- package/client/layouts/components/app-sidebar.tsx +196 -0
- package/client/layouts/components/header-actions.tsx +50 -0
- package/client/layouts/components/language-switcher.tsx +95 -0
- package/client/layouts/components/layout-header.tsx +17 -0
- package/client/layouts/components/navigation-menu.tsx +411 -0
- package/client/layouts/components/sidebar-footer.tsx +107 -0
- package/client/layouts/components/surface-empty.tsx +35 -0
- package/client/layouts/components/user-menu.tsx +127 -0
- package/client/layouts/index.ts +2 -0
- package/client/layouts/settings-layout.tsx +172 -0
- package/client/layouts/use-sidebar-preference.ts +57 -0
- package/client/lib/constants.ts +5 -0
- package/client/lib/toaster.ts +56 -0
- package/client/lib/utils.ts +1 -0
- package/client/locales/en-US.ts +178 -0
- package/client/locales/index.ts +9 -0
- package/client/locales/zh-CN.ts +161 -0
- package/client/pages/auth/device.tsx +41 -0
- package/client/pages/auth/forgot-password.tsx +39 -0
- package/client/pages/auth/login.tsx +66 -0
- package/client/pages/auth/register.tsx +46 -0
- package/client/pages/auth/reset-password.tsx +38 -0
- package/client/pages/auth/shared.tsx +111 -0
- package/client/pages/home.tsx +16 -0
- package/client/pages/inbox.tsx +12 -0
- package/client/plugins.ts +41 -0
- package/client/react-providers.ts +27 -0
- package/client/route-overrides.ts +9 -0
- package/client/routes.ts +75 -0
- package/client/routing/app-router.tsx +127 -0
- package/client/routing/client-route.tsx +129 -0
- package/client/routing/route-context.tsx +85 -0
- package/client/routing/route-navigation.ts +153 -0
- package/client/routing/route-tree.tsx +42 -0
- package/client/routing/standalone-page-layout.tsx +15 -0
- package/client/runtime.ts +26 -0
- package/client/service-provider.ts +40 -0
- package/client/source-extensions.ts +19 -0
- package/client/startup.tsx +22 -0
- package/client/styles.css +122 -0
- package/client/theme/index.ts +4 -0
- package/client/theme/theme-context.ts +12 -0
- package/client/theme/theme-preferences.ts +84 -0
- package/client/theme/theme-presets.ts +7 -0
- package/client/theme/theme-provider.tsx +106 -0
- package/client/theme/theme-settings.tsx +176 -0
- package/client/theme/themes/compact.css +140 -0
- package/client/theme/themes/default.css +117 -0
- package/client/vite-env.d.ts +5 -0
- package/components.json +27 -0
- package/config.example.yml +172 -0
- package/database/main/migrations/.gitkeep +0 -0
- package/database/main/migrations/202610060001_create_device_code.ts +37 -0
- package/database/main/seeds/.gitkeep +0 -0
- package/database/tsconfig.json +4 -0
- package/ecosystem.config.js +35 -0
- package/eslint.config.js +24 -0
- package/index.html +100 -0
- package/package.json +200 -3
- package/playwright.config.ts +10 -0
- package/public/assets/favicon.ico +0 -0
- package/public/assets/logo-dark.png +0 -0
- package/public/assets/logo-mark-dark.png +0 -0
- package/public/assets/logo-mark.png +0 -0
- package/public/assets/logo.png +0 -0
- package/server/app.ts +50 -0
- package/server/config/ai.ts +27 -0
- package/server/config/api.ts +8 -0
- package/server/config/app.ts +40 -0
- package/server/config/auth.ts +36 -0
- package/server/config/authorization.ts +18 -0
- package/server/config/caching.ts +21 -0
- package/server/config/database.ts +22 -0
- package/server/config/drive.ts +30 -0
- package/server/config/i18n.ts +17 -0
- package/server/config/index.ts +73 -0
- package/server/config/jobs.ts +27 -0
- package/server/config/logging.ts +23 -0
- package/server/config/notification.ts +11 -0
- package/server/config/queue.ts +28 -0
- package/server/config/scheduler.ts +23 -0
- package/server/config/secrets.ts +10 -0
- package/server/config/server.ts +26 -0
- package/server/config/session.ts +60 -0
- package/server/config/snowflake.ts +21 -0
- package/server/config/spa.ts +24 -0
- package/server/config/users.ts +11 -0
- package/server/config/workflow.ts +24 -0
- package/server/config.ts +24 -0
- package/server/embedded.ts +23 -0
- package/server/locales/en-US.ts +10 -0
- package/server/locales/index.ts +10 -0
- package/server/locales/zh-CN.ts +5 -0
- package/server/plugins.ts +41 -0
- package/server/providers/index.ts +5 -0
- package/server/routes/index.ts +6 -0
- package/server/runtime.ts +24 -0
- package/server/standalone.ts +29 -0
- package/server/tsconfig.json +4 -0
- package/tailwind.config.mjs +59 -0
- package/tests/components/app-layout-routes.test.tsx +132 -0
- package/tests/components/app-title.test.tsx +57 -0
- package/tests/components/auth-i18n.test.tsx +87 -0
- package/tests/components/back-button.test.tsx +80 -0
- package/tests/components/file-preview.test.tsx +101 -0
- package/tests/components/header-hover.test.tsx +87 -0
- package/tests/components/inbox.test.tsx +139 -0
- package/tests/components/language-switcher.test.tsx +298 -0
- package/tests/components/layout-containers.test.tsx +15 -0
- package/tests/components/layout-i18n.test.tsx +187 -0
- package/tests/components/loading.test.tsx +25 -0
- package/tests/components/navigation-menu.test.tsx +355 -0
- package/tests/components/page-harness.test.tsx +318 -0
- package/tests/components/primitive-labels.test.tsx +80 -0
- package/tests/components/sidebar-permissions.test.tsx +110 -0
- package/tests/components/sidebar-preference.test.tsx +42 -0
- package/tests/fixtures/theme-tokens.html +12 -0
- package/tests/fixtures/theme-tokens.tsx +111 -0
- package/tests/logic/account-permissions.test.tsx +204 -0
- package/tests/logic/app-locale-coverage.test.ts +136 -0
- package/tests/logic/app-server.test.ts +1589 -0
- package/tests/logic/asset-url-build.test.ts +106 -0
- package/tests/logic/client-auth.test.tsx +122 -0
- package/tests/logic/client-plugin-registry.test.ts +63 -0
- package/tests/logic/client-route-recovery.test.tsx +80 -0
- package/tests/logic/client-routes.test.ts +126 -0
- package/tests/logic/client-runtime.test.ts +112 -0
- package/tests/logic/client-settings.test.tsx +841 -0
- package/tests/logic/client-shell.test.tsx +466 -0
- package/tests/logic/client-theme.test.tsx +441 -0
- package/tests/logic/config.test.ts +197 -0
- package/tests/logic/dev-client-watch.test.ts +108 -0
- package/tests/logic/dev-routes-production-build.test.ts +168 -0
- package/tests/logic/device-code-migration.test.ts +70 -0
- package/tests/logic/lifecycle.test.ts +221 -0
- package/tests/logic/plugin-commands.test.ts +69 -0
- package/tests/logic/plugin-page-authz.test.ts +76 -0
- package/tests/logic/route-navigation.test.ts +66 -0
- package/tests/logic/route-overlay.test.tsx +353 -0
- package/tests/logic/server-imports.test.ts +88 -0
- package/tests/logic/tailwind-sources.test.ts +151 -0
- package/tests/logic/template-package-name.test.ts +88 -0
- package/tests/logic/theme-preferences.test.ts +86 -0
- package/tests/logic/theme-tokens.test.ts +248 -0
- package/tests/logic/toaster.test.tsx +108 -0
- package/tests/logic/user-menu-sign-out.test.tsx +98 -0
- package/tests/setup/client-config.ts +27 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +12 -0
- package/tsconfig.server.json +19 -0
- package/vite.config.ts +92 -0
- package/vitest.config.ts +37 -0
- package/README.md +0 -3
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircle2Icon,
|
|
3
|
+
ChevronRightIcon,
|
|
4
|
+
MoreHorizontalIcon,
|
|
5
|
+
} from 'lucide-react';
|
|
6
|
+
import { createElement, type KeyboardEvent, type ReactElement } from 'react';
|
|
7
|
+
|
|
8
|
+
import { Button } from '#components/ui/button';
|
|
9
|
+
import {
|
|
10
|
+
ContextMenu,
|
|
11
|
+
ContextMenuContent,
|
|
12
|
+
ContextMenuGroup,
|
|
13
|
+
ContextMenuItem,
|
|
14
|
+
ContextMenuTrigger,
|
|
15
|
+
} from '#components/ui/context-menu';
|
|
16
|
+
import {
|
|
17
|
+
DropdownMenu,
|
|
18
|
+
DropdownMenuContent,
|
|
19
|
+
DropdownMenuGroup,
|
|
20
|
+
DropdownMenuItem,
|
|
21
|
+
DropdownMenuTrigger,
|
|
22
|
+
} from '#components/ui/dropdown-menu';
|
|
23
|
+
import { cn } from 'cn';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
actionLabel,
|
|
27
|
+
useInboxFrameText,
|
|
28
|
+
type InboxFrameText,
|
|
29
|
+
} from './frame.js';
|
|
30
|
+
import {
|
|
31
|
+
INBOX_ACTION_ICON,
|
|
32
|
+
inboxActionsFor,
|
|
33
|
+
type InboxAction,
|
|
34
|
+
} from './inbox-actions.js';
|
|
35
|
+
import { isSettled, kindOf, relativeTime, type InboxEntry } from './model.js';
|
|
36
|
+
import { ContributorScope } from './registry-scope.js';
|
|
37
|
+
import {
|
|
38
|
+
rendererKey,
|
|
39
|
+
useRendererOf,
|
|
40
|
+
type InboxEntryRenderer,
|
|
41
|
+
} from './registry.js';
|
|
42
|
+
|
|
43
|
+
export interface InboxTypeIconProps {
|
|
44
|
+
readonly entry: InboxEntry;
|
|
45
|
+
readonly renderer: InboxEntryRenderer<never>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The icon of an item: its contributor's, amber for a decision that still waits, a check once its decision is
|
|
50
|
+
* settled.
|
|
51
|
+
*/
|
|
52
|
+
export function InboxTypeIcon({
|
|
53
|
+
entry,
|
|
54
|
+
renderer,
|
|
55
|
+
}: InboxTypeIconProps): ReactElement {
|
|
56
|
+
if (isSettled(entry))
|
|
57
|
+
return (
|
|
58
|
+
<CheckCircle2Icon
|
|
59
|
+
className='size-4 shrink-0 text-emerald-600 dark:text-emerald-400'
|
|
60
|
+
aria-hidden='true'
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
// The contributor's icon component, chosen per item.
|
|
64
|
+
return createElement(renderer.icon(entry), {
|
|
65
|
+
'aria-hidden': true,
|
|
66
|
+
className: cn(
|
|
67
|
+
'size-4 shrink-0',
|
|
68
|
+
kindOf(entry) === 'decision'
|
|
69
|
+
? 'text-amber-600 dark:text-amber-400'
|
|
70
|
+
: 'text-muted-foreground',
|
|
71
|
+
),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface InboxItemCardProps {
|
|
76
|
+
readonly entry: InboxEntry;
|
|
77
|
+
readonly selected: boolean;
|
|
78
|
+
readonly busy: boolean;
|
|
79
|
+
/** Arrived by a realtime push after the list first loaded: slides in. */
|
|
80
|
+
readonly fresh?: boolean;
|
|
81
|
+
readonly onSelect: (entry: InboxEntry) => void;
|
|
82
|
+
readonly onOpen: (entry: InboxEntry) => void;
|
|
83
|
+
readonly onAction: (entry: InboxEntry, action: InboxAction) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A card, worded by the item's contributor in its own namespace (`registry.ts`). */
|
|
87
|
+
export function InboxItemCard(props: InboxItemCardProps): ReactElement {
|
|
88
|
+
const renderer = useRendererOf()(props.entry);
|
|
89
|
+
const frame = useInboxFrameText();
|
|
90
|
+
return (
|
|
91
|
+
<ContributorScope
|
|
92
|
+
key={rendererKey(renderer)}
|
|
93
|
+
namespace={renderer.namespace}
|
|
94
|
+
resources={renderer.resources}
|
|
95
|
+
>
|
|
96
|
+
<ItemCard {...props} renderer={renderer} frame={frame} />
|
|
97
|
+
</ContributorScope>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One compact card of the inbox list. Clicking anywhere on it shows it in the detail pane (and marks it read), as its
|
|
103
|
+
* chevron says; Enter on a focused card opens its route. The card reads: what it is about (an issue's identifier), type, ×N and time, then its title,
|
|
104
|
+
* then one sentence, as its contributor words them. A decision that still waits carries an amber bar, an unread card a
|
|
105
|
+
* dot and a bold title, a settled decision is dimmed with a check, and an item that arrived after the list loaded
|
|
106
|
+
* slides in. The menu button and a right-click offer read / unread and delete.
|
|
107
|
+
*/
|
|
108
|
+
function ItemCard({
|
|
109
|
+
entry,
|
|
110
|
+
renderer,
|
|
111
|
+
frame,
|
|
112
|
+
selected,
|
|
113
|
+
busy,
|
|
114
|
+
fresh,
|
|
115
|
+
onSelect,
|
|
116
|
+
onOpen,
|
|
117
|
+
onAction,
|
|
118
|
+
}: InboxItemCardProps & {
|
|
119
|
+
readonly renderer: InboxEntryRenderer<never>;
|
|
120
|
+
readonly frame: InboxFrameText;
|
|
121
|
+
}): ReactElement {
|
|
122
|
+
const { t, language } = frame;
|
|
123
|
+
const wording = renderer.useWording();
|
|
124
|
+
const { item, notice } = entry;
|
|
125
|
+
const { title, sentence } = wording.text(entry);
|
|
126
|
+
const unread = !item.readAt;
|
|
127
|
+
const settled = isSettled(entry);
|
|
128
|
+
const waiting = kindOf(entry) === 'decision' && !settled;
|
|
129
|
+
const actions = inboxActionsFor(entry);
|
|
130
|
+
|
|
131
|
+
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>): void {
|
|
132
|
+
if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
onOpen(entry);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const menuItems = (
|
|
139
|
+
Item: typeof DropdownMenuItem | typeof ContextMenuItem,
|
|
140
|
+
): ReactElement[] =>
|
|
141
|
+
actions.map((action) => {
|
|
142
|
+
const Icon = INBOX_ACTION_ICON[action];
|
|
143
|
+
return (
|
|
144
|
+
<Item
|
|
145
|
+
key={action}
|
|
146
|
+
disabled={busy}
|
|
147
|
+
onClick={() => onAction(entry, action)}
|
|
148
|
+
>
|
|
149
|
+
<Icon />
|
|
150
|
+
{actionLabel(t, action)}
|
|
151
|
+
</Item>
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<ContextMenu>
|
|
157
|
+
<ContextMenuTrigger
|
|
158
|
+
data-inbox-item={item.id}
|
|
159
|
+
data-selected={selected ? 'true' : undefined}
|
|
160
|
+
className={cn(
|
|
161
|
+
'group relative flex items-start gap-3 rounded-lg border border-transparent p-3 pr-2 transition-[background-color,border-color,opacity] duration-200',
|
|
162
|
+
selected ? 'border-border bg-card shadow-xs' : 'hover:bg-accent/60',
|
|
163
|
+
settled && 'opacity-55',
|
|
164
|
+
fresh &&
|
|
165
|
+
'animate-in duration-200 fade-in slide-in-from-top-1 motion-reduce:animate-none',
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
<span
|
|
169
|
+
aria-hidden='true'
|
|
170
|
+
className={cn(
|
|
171
|
+
'absolute top-3 bottom-3 left-0 w-0.5 rounded-full',
|
|
172
|
+
selected
|
|
173
|
+
? 'bg-primary'
|
|
174
|
+
: waiting
|
|
175
|
+
? 'bg-amber-500'
|
|
176
|
+
: 'bg-transparent',
|
|
177
|
+
)}
|
|
178
|
+
/>
|
|
179
|
+
<span className='mt-0.5 flex shrink-0'>
|
|
180
|
+
<InboxTypeIcon entry={entry} renderer={renderer} />
|
|
181
|
+
</span>
|
|
182
|
+
<button
|
|
183
|
+
type='button'
|
|
184
|
+
aria-current={selected ? 'true' : undefined}
|
|
185
|
+
// Stretched over the whole card, so a click anywhere on it selects the item.
|
|
186
|
+
className="block min-w-0 flex-1 cursor-pointer space-y-1 text-left after:absolute after:inset-0 after:rounded-lg after:content-[''] focus-visible:outline-none"
|
|
187
|
+
onClick={() => onSelect(entry)}
|
|
188
|
+
onKeyDown={onKeyDown}
|
|
189
|
+
>
|
|
190
|
+
<span className='flex items-center gap-1.5 text-xs text-muted-foreground'>
|
|
191
|
+
{notice?.subject?.label ? (
|
|
192
|
+
<span className='shrink-0 font-mono'>{notice.subject.label}</span>
|
|
193
|
+
) : null}
|
|
194
|
+
<span className='truncate'>{wording.label(entry)}</span>
|
|
195
|
+
{notice && notice.count > 1 ? (
|
|
196
|
+
<span className='shrink-0 tabular-nums'>
|
|
197
|
+
{t('inbox.count', {
|
|
198
|
+
count: notice.count,
|
|
199
|
+
defaultValue: '×{{count}}',
|
|
200
|
+
})}
|
|
201
|
+
</span>
|
|
202
|
+
) : null}
|
|
203
|
+
{settled ? (
|
|
204
|
+
<span className='shrink-0'>
|
|
205
|
+
{t('inbox.resolved', { defaultValue: 'Handled' })}
|
|
206
|
+
</span>
|
|
207
|
+
) : null}
|
|
208
|
+
{/* The latest notice's time: a merged item (×N) is sent anew, replacing the previous one. */}
|
|
209
|
+
<time
|
|
210
|
+
className='ml-auto shrink-0 tabular-nums'
|
|
211
|
+
dateTime={item.createdAt}
|
|
212
|
+
title={new Date(item.createdAt).toLocaleString(language)}
|
|
213
|
+
>
|
|
214
|
+
{relativeTime(item.createdAt, language)}
|
|
215
|
+
</time>
|
|
216
|
+
</span>
|
|
217
|
+
<span
|
|
218
|
+
className={cn(
|
|
219
|
+
'flex items-center gap-1.5 text-sm group-focus-within:underline',
|
|
220
|
+
unread ? 'font-semibold' : 'font-normal',
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
{unread ? (
|
|
224
|
+
<span className='size-1.5 shrink-0 rounded-full bg-primary'>
|
|
225
|
+
<span className='sr-only'>
|
|
226
|
+
{t('inbox.unread', { defaultValue: 'Unread' })}
|
|
227
|
+
</span>
|
|
228
|
+
</span>
|
|
229
|
+
) : null}
|
|
230
|
+
<span className='truncate'>{title}</span>
|
|
231
|
+
</span>
|
|
232
|
+
{sentence ? (
|
|
233
|
+
<span className='line-clamp-1 text-sm text-muted-foreground wrap-anywhere'>
|
|
234
|
+
{sentence}
|
|
235
|
+
</span>
|
|
236
|
+
) : null}
|
|
237
|
+
</button>
|
|
238
|
+
<DropdownMenu>
|
|
239
|
+
<DropdownMenuTrigger
|
|
240
|
+
render={
|
|
241
|
+
<Button
|
|
242
|
+
variant='ghost'
|
|
243
|
+
size='icon-xs'
|
|
244
|
+
disabled={busy}
|
|
245
|
+
className='relative opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-popup-open:opacity-100'
|
|
246
|
+
aria-label={t('inbox.actionsFor', {
|
|
247
|
+
title,
|
|
248
|
+
defaultValue: 'Actions for {{title}}',
|
|
249
|
+
})}
|
|
250
|
+
/>
|
|
251
|
+
}
|
|
252
|
+
>
|
|
253
|
+
<MoreHorizontalIcon />
|
|
254
|
+
</DropdownMenuTrigger>
|
|
255
|
+
<DropdownMenuContent align='end' className='w-auto min-w-40'>
|
|
256
|
+
<DropdownMenuGroup>{menuItems(DropdownMenuItem)}</DropdownMenuGroup>
|
|
257
|
+
</DropdownMenuContent>
|
|
258
|
+
</DropdownMenu>
|
|
259
|
+
<ChevronRightIcon
|
|
260
|
+
aria-hidden='true'
|
|
261
|
+
className={cn(
|
|
262
|
+
'size-4 shrink-0 self-center transition-colors',
|
|
263
|
+
selected
|
|
264
|
+
? 'text-foreground'
|
|
265
|
+
: 'text-muted-foreground group-hover:text-foreground',
|
|
266
|
+
)}
|
|
267
|
+
/>
|
|
268
|
+
</ContextMenuTrigger>
|
|
269
|
+
<ContextMenuContent>
|
|
270
|
+
<ContextMenuGroup>{menuItems(ContextMenuItem)}</ContextMenuGroup>
|
|
271
|
+
</ContextMenuContent>
|
|
272
|
+
</ContextMenu>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { AlertCircleIcon, InboxIcon } from 'lucide-react';
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Alert,
|
|
6
|
+
AlertAction,
|
|
7
|
+
AlertDescription,
|
|
8
|
+
AlertTitle,
|
|
9
|
+
} from '#components/ui/alert';
|
|
10
|
+
import { Badge } from '#components/ui/badge';
|
|
11
|
+
import { Button } from '#components/ui/button';
|
|
12
|
+
import {
|
|
13
|
+
Empty,
|
|
14
|
+
EmptyContent,
|
|
15
|
+
EmptyDescription,
|
|
16
|
+
EmptyHeader,
|
|
17
|
+
EmptyMedia,
|
|
18
|
+
EmptyTitle,
|
|
19
|
+
} from '#components/ui/empty';
|
|
20
|
+
import { Kbd } from '#components/ui/kbd';
|
|
21
|
+
import {
|
|
22
|
+
Select,
|
|
23
|
+
SelectContent,
|
|
24
|
+
SelectItem,
|
|
25
|
+
SelectTrigger,
|
|
26
|
+
SelectValue,
|
|
27
|
+
} from '#components/ui/select';
|
|
28
|
+
import { Skeleton } from '#components/ui/skeleton';
|
|
29
|
+
import { Spinner } from '#components/ui/spinner';
|
|
30
|
+
import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
|
|
31
|
+
import { cn } from 'cn';
|
|
32
|
+
|
|
33
|
+
import { useInboxFrameText } from './frame.js';
|
|
34
|
+
import type { InboxAction } from './inbox-actions.js';
|
|
35
|
+
import { InboxItemCard } from './inbox-item.js';
|
|
36
|
+
import {
|
|
37
|
+
categoriesOf,
|
|
38
|
+
INBOX_VIEWS,
|
|
39
|
+
kindsOf,
|
|
40
|
+
shownGroups,
|
|
41
|
+
type InboxEntry,
|
|
42
|
+
type InboxFilter,
|
|
43
|
+
type InboxGroups,
|
|
44
|
+
type InboxView,
|
|
45
|
+
} from './model.js';
|
|
46
|
+
import { categoriesOfRegistry, useInboxRegistry } from './registry.js';
|
|
47
|
+
|
|
48
|
+
export interface InboxListQuery {
|
|
49
|
+
readonly isError: boolean;
|
|
50
|
+
readonly error: unknown;
|
|
51
|
+
readonly hasNextPage: boolean;
|
|
52
|
+
readonly isFetchingNextPage: boolean;
|
|
53
|
+
refetch(): unknown;
|
|
54
|
+
fetchNextPage(): unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a collection category shows in the list: its group while every kind shows, its list while selected. */
|
|
58
|
+
export interface InboxListCollection {
|
|
59
|
+
/** Its group, after the category it follows. */
|
|
60
|
+
readonly group?: ReactNode;
|
|
61
|
+
/** How many records its group lists; the group shows only above zero. */
|
|
62
|
+
readonly count?: number;
|
|
63
|
+
/** Its list, in place of the items while the kind filter selects it. */
|
|
64
|
+
readonly list?: ReactNode;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface InboxListProps {
|
|
68
|
+
readonly filter: InboxFilter;
|
|
69
|
+
/** Unread items among those loaded, the count on All. */
|
|
70
|
+
readonly unread: number;
|
|
71
|
+
/** What waits on the viewer, the count on To do and the header badge's. */
|
|
72
|
+
readonly pending: number;
|
|
73
|
+
/** What waits in each waiting category, beside its group's heading; its unsettled items when left out. */
|
|
74
|
+
readonly waiting?: Readonly<Record<string, number>>;
|
|
75
|
+
readonly groups: InboxGroups;
|
|
76
|
+
readonly loaded: boolean;
|
|
77
|
+
readonly query: InboxListQuery;
|
|
78
|
+
readonly selectedId: string | null;
|
|
79
|
+
/** Items that arrived after the list first loaded. */
|
|
80
|
+
readonly fresh?: ReadonlySet<string>;
|
|
81
|
+
readonly busy: boolean;
|
|
82
|
+
readonly onFilter: (filter: InboxFilter) => void;
|
|
83
|
+
readonly onSelect: (entry: InboxEntry) => void;
|
|
84
|
+
readonly onOpen: (entry: InboxEntry) => void;
|
|
85
|
+
readonly onAction: (entry: InboxEntry, action: InboxAction) => void;
|
|
86
|
+
/** The feeds' sections (decisions kept in a contributor's own API), shown first with every kind or decisions. */
|
|
87
|
+
readonly feeds?: ReactNode;
|
|
88
|
+
readonly feedCount?: number;
|
|
89
|
+
/** What each collection category shows, by its id. */
|
|
90
|
+
readonly collections?: Readonly<Record<string, InboxListCollection>>;
|
|
91
|
+
/** Beside the kind filter, such as a sound reminder switch. */
|
|
92
|
+
readonly footer?: ReactNode;
|
|
93
|
+
/** The prefix of the element ids it renders. */
|
|
94
|
+
readonly idPrefix?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The inbox's left column: two views — All, and To do with the count of what waits on the viewer, the same number
|
|
99
|
+
* as the header badge — then one filter by kind (every kind, then the registry's categories) and the `footer` slot.
|
|
100
|
+
* Below, the items in groups: the feeds' sections, each entry category's group with each collection's group after the
|
|
101
|
+
* category it follows, and "load more". To do lists only what still waits; its kind filter offers only the waiting
|
|
102
|
+
* categories and the collections offered there. A collection selected by the kind filter lists its own records in
|
|
103
|
+
* place of the items. The keyboard hint sits at the bottom.
|
|
104
|
+
*/
|
|
105
|
+
export function InboxList({
|
|
106
|
+
filter,
|
|
107
|
+
unread,
|
|
108
|
+
pending,
|
|
109
|
+
waiting,
|
|
110
|
+
groups,
|
|
111
|
+
loaded,
|
|
112
|
+
query,
|
|
113
|
+
selectedId,
|
|
114
|
+
fresh,
|
|
115
|
+
busy,
|
|
116
|
+
onFilter,
|
|
117
|
+
onSelect,
|
|
118
|
+
onOpen,
|
|
119
|
+
onAction,
|
|
120
|
+
feeds,
|
|
121
|
+
feedCount = 0,
|
|
122
|
+
collections = {},
|
|
123
|
+
footer,
|
|
124
|
+
idPrefix = 'nocobase-inbox',
|
|
125
|
+
}: InboxListProps): ReactElement {
|
|
126
|
+
const { t } = useInboxFrameText();
|
|
127
|
+
const categories = categoriesOfRegistry(useInboxRegistry());
|
|
128
|
+
const { view, kind } = filter;
|
|
129
|
+
const shown = shownGroups(filter, groups, categories);
|
|
130
|
+
const selectedCollection = categories.find(
|
|
131
|
+
(category) => category.type === 'collection' && category.id === kind,
|
|
132
|
+
);
|
|
133
|
+
const shownCollections =
|
|
134
|
+
kind === 'all'
|
|
135
|
+
? categoriesOf(view, categories).filter(
|
|
136
|
+
(category) =>
|
|
137
|
+
category.type === 'collection' &&
|
|
138
|
+
(collections[category.id]?.count ?? 0) > 0,
|
|
139
|
+
)
|
|
140
|
+
: [];
|
|
141
|
+
const firstEntries = shown[0]?.category.id;
|
|
142
|
+
const showFeeds = (kind === 'all' || kind === firstEntries) && feedCount > 0;
|
|
143
|
+
const empty =
|
|
144
|
+
loaded &&
|
|
145
|
+
!showFeeds &&
|
|
146
|
+
shownCollections.length === 0 &&
|
|
147
|
+
shown.every(({ entries }) => entries.length === 0);
|
|
148
|
+
const kinds = kindsOf(view, categories).map((value) => ({
|
|
149
|
+
value,
|
|
150
|
+
label:
|
|
151
|
+
value === 'all'
|
|
152
|
+
? t('inbox.kinds.all', { defaultValue: 'All types' })
|
|
153
|
+
: (categories.find((category) => category.id === value)?.label(t) ??
|
|
154
|
+
value),
|
|
155
|
+
}));
|
|
156
|
+
// Each collection's group follows the category it names; one naming no category shown comes last.
|
|
157
|
+
const shownIds = new Set(shown.map(({ category }) => category.id));
|
|
158
|
+
const after = (id: string | null): ReactNode =>
|
|
159
|
+
shownCollections
|
|
160
|
+
.filter((category) => {
|
|
161
|
+
const target =
|
|
162
|
+
category.type === 'collection' ? category.after : undefined;
|
|
163
|
+
return id === null
|
|
164
|
+
? target === undefined || !shownIds.has(target)
|
|
165
|
+
: target === id;
|
|
166
|
+
})
|
|
167
|
+
.map((category) => (
|
|
168
|
+
<CollectionSlot key={category.id}>
|
|
169
|
+
{collections[category.id]?.group}
|
|
170
|
+
</CollectionSlot>
|
|
171
|
+
));
|
|
172
|
+
|
|
173
|
+
let content: ReactNode;
|
|
174
|
+
if (selectedCollection) content = collections[selectedCollection.id]?.list;
|
|
175
|
+
else if (query.isError && !loaded)
|
|
176
|
+
content = (
|
|
177
|
+
<div className='px-1'>
|
|
178
|
+
<Alert variant='destructive'>
|
|
179
|
+
<AlertCircleIcon />
|
|
180
|
+
<AlertTitle>
|
|
181
|
+
{t('inbox.loadFailed', {
|
|
182
|
+
defaultValue: 'Unable to load the inbox',
|
|
183
|
+
})}
|
|
184
|
+
</AlertTitle>
|
|
185
|
+
<AlertDescription>
|
|
186
|
+
{t('inbox.loadFailedDescription', {
|
|
187
|
+
defaultValue: 'Check your connection and try again.',
|
|
188
|
+
})}
|
|
189
|
+
</AlertDescription>
|
|
190
|
+
<AlertAction>
|
|
191
|
+
<Button
|
|
192
|
+
variant='outline'
|
|
193
|
+
size='sm'
|
|
194
|
+
onClick={() => void query.refetch()}
|
|
195
|
+
>
|
|
196
|
+
{t('inbox.retry', { defaultValue: 'Retry' })}
|
|
197
|
+
</Button>
|
|
198
|
+
</AlertAction>
|
|
199
|
+
</Alert>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
else if (!loaded)
|
|
203
|
+
content = (
|
|
204
|
+
<div
|
|
205
|
+
role='status'
|
|
206
|
+
aria-label={t('inbox.loading', { defaultValue: 'Loading' })}
|
|
207
|
+
className='space-y-2 px-1'
|
|
208
|
+
>
|
|
209
|
+
{Array.from({ length: 5 }, (_, index) => (
|
|
210
|
+
<Skeleton key={index} className='h-16 w-full rounded-lg' />
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
else if (empty)
|
|
215
|
+
content =
|
|
216
|
+
kind === 'all' ? (
|
|
217
|
+
<ListEmpty
|
|
218
|
+
title={
|
|
219
|
+
view === 'todo'
|
|
220
|
+
? t('inbox.empty.todo', {
|
|
221
|
+
defaultValue: 'Nothing waiting for you',
|
|
222
|
+
})
|
|
223
|
+
: t('inbox.emptyAll', { defaultValue: 'Nothing here' })
|
|
224
|
+
}
|
|
225
|
+
description={t('inbox.emptyDescription', {
|
|
226
|
+
defaultValue:
|
|
227
|
+
'Notifications and requests sent to you show up here.',
|
|
228
|
+
})}
|
|
229
|
+
/>
|
|
230
|
+
) : (
|
|
231
|
+
<ListEmpty
|
|
232
|
+
title={shown[0]?.category.empty(t) ?? ''}
|
|
233
|
+
description={t('inbox.noMatchDescription', {
|
|
234
|
+
defaultValue: 'Clear the filter to see every type.',
|
|
235
|
+
})}
|
|
236
|
+
action={
|
|
237
|
+
<Button
|
|
238
|
+
variant='outline'
|
|
239
|
+
size='sm'
|
|
240
|
+
onClick={() => onFilter({ view, kind: 'all' })}
|
|
241
|
+
>
|
|
242
|
+
{t('inbox.clearFilter', { defaultValue: 'Clear filter' })}
|
|
243
|
+
</Button>
|
|
244
|
+
}
|
|
245
|
+
/>
|
|
246
|
+
);
|
|
247
|
+
else
|
|
248
|
+
content = (
|
|
249
|
+
<div className='space-y-5'>
|
|
250
|
+
{showFeeds ? feeds : null}
|
|
251
|
+
{shown.map(({ category, entries }) => {
|
|
252
|
+
const heading = `${idPrefix}-group-${category.id}`;
|
|
253
|
+
const count = category.waits
|
|
254
|
+
? (waiting?.[category.id] ??
|
|
255
|
+
entries.filter((entry) => entry.notice?.resolvedAt == null)
|
|
256
|
+
.length)
|
|
257
|
+
: entries.length;
|
|
258
|
+
return (
|
|
259
|
+
<CollectionSlot key={category.id}>
|
|
260
|
+
{entries.length === 0 && kind === 'all' ? null : (
|
|
261
|
+
<section aria-labelledby={heading} className='space-y-1'>
|
|
262
|
+
<h2
|
|
263
|
+
id={heading}
|
|
264
|
+
className='flex items-center gap-2 px-3 text-xs font-medium tracking-wider text-muted-foreground uppercase'
|
|
265
|
+
>
|
|
266
|
+
{category.title(t)}
|
|
267
|
+
{/* What still waits, as To do counts it; handled items stay listed in All but are not counted. */}
|
|
268
|
+
{count > 0 ? (
|
|
269
|
+
<span className='tabular-nums'>{count}</span>
|
|
270
|
+
) : null}
|
|
271
|
+
</h2>
|
|
272
|
+
<ul aria-label={category.title(t)} className='space-y-0.5'>
|
|
273
|
+
{entries.map((entry) => (
|
|
274
|
+
<li key={entry.item.id}>
|
|
275
|
+
<InboxItemCard
|
|
276
|
+
entry={entry}
|
|
277
|
+
selected={entry.item.id === selectedId}
|
|
278
|
+
busy={busy}
|
|
279
|
+
fresh={fresh?.has(entry.item.id)}
|
|
280
|
+
onSelect={onSelect}
|
|
281
|
+
onOpen={onOpen}
|
|
282
|
+
onAction={onAction}
|
|
283
|
+
/>
|
|
284
|
+
</li>
|
|
285
|
+
))}
|
|
286
|
+
</ul>
|
|
287
|
+
</section>
|
|
288
|
+
)}
|
|
289
|
+
{after(category.id)}
|
|
290
|
+
</CollectionSlot>
|
|
291
|
+
);
|
|
292
|
+
})}
|
|
293
|
+
{after(null)}
|
|
294
|
+
{query.hasNextPage ? (
|
|
295
|
+
<div className='flex justify-center pt-1'>
|
|
296
|
+
<Button
|
|
297
|
+
variant='ghost'
|
|
298
|
+
size='sm'
|
|
299
|
+
disabled={query.isFetchingNextPage}
|
|
300
|
+
onClick={() => void query.fetchNextPage()}
|
|
301
|
+
>
|
|
302
|
+
{query.isFetchingNextPage ? (
|
|
303
|
+
<Spinner data-icon='inline-start' />
|
|
304
|
+
) : null}
|
|
305
|
+
{t('inbox.loadMore', { defaultValue: 'Load more' })}
|
|
306
|
+
</Button>
|
|
307
|
+
</div>
|
|
308
|
+
) : null}
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<>
|
|
314
|
+
<div className='shrink-0 space-y-2 border-b px-3 py-2.5'>
|
|
315
|
+
<Tabs
|
|
316
|
+
value={view}
|
|
317
|
+
onValueChange={(value) => {
|
|
318
|
+
const next = value as InboxView;
|
|
319
|
+
onFilter({
|
|
320
|
+
view: next,
|
|
321
|
+
kind: kindsOf(next, categories).includes(kind) ? kind : 'all',
|
|
322
|
+
});
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<TabsList variant='line' className='w-full'>
|
|
326
|
+
{INBOX_VIEWS.map((value) => {
|
|
327
|
+
const count = value === 'todo' ? pending : unread;
|
|
328
|
+
return (
|
|
329
|
+
<TabsTrigger key={value} value={value}>
|
|
330
|
+
{value === 'todo'
|
|
331
|
+
? t('inbox.views.todo', { defaultValue: 'To do' })
|
|
332
|
+
: t('inbox.views.all', { defaultValue: 'All' })}
|
|
333
|
+
{count > 0 ? (
|
|
334
|
+
<Badge
|
|
335
|
+
variant={value === 'todo' ? 'outline' : 'secondary'}
|
|
336
|
+
className={cn(
|
|
337
|
+
'h-4 px-1.5 tabular-nums',
|
|
338
|
+
value === 'todo' &&
|
|
339
|
+
'border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-300',
|
|
340
|
+
)}
|
|
341
|
+
aria-label={
|
|
342
|
+
value === 'todo'
|
|
343
|
+
? t('inbox.pendingDecisions', {
|
|
344
|
+
count,
|
|
345
|
+
defaultValue: '{{count}} waiting',
|
|
346
|
+
})
|
|
347
|
+
: t('inbox.unreadCount', {
|
|
348
|
+
count,
|
|
349
|
+
defaultValue: '{{count}} unread',
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
>
|
|
353
|
+
{count}
|
|
354
|
+
</Badge>
|
|
355
|
+
) : null}
|
|
356
|
+
</TabsTrigger>
|
|
357
|
+
);
|
|
358
|
+
})}
|
|
359
|
+
</TabsList>
|
|
360
|
+
</Tabs>
|
|
361
|
+
<div className='flex flex-wrap items-center justify-between gap-2'>
|
|
362
|
+
<Select
|
|
363
|
+
items={kinds}
|
|
364
|
+
value={kind}
|
|
365
|
+
onValueChange={(next: string | null) => {
|
|
366
|
+
if (next) onFilter({ view, kind: next });
|
|
367
|
+
}}
|
|
368
|
+
>
|
|
369
|
+
<SelectTrigger
|
|
370
|
+
size='sm'
|
|
371
|
+
className='w-40'
|
|
372
|
+
aria-label={t('inbox.kinds.label', { defaultValue: 'Type' })}
|
|
373
|
+
>
|
|
374
|
+
<SelectValue />
|
|
375
|
+
</SelectTrigger>
|
|
376
|
+
<SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
|
|
377
|
+
{kinds.map((item) => (
|
|
378
|
+
<SelectItem key={item.value} value={item.value}>
|
|
379
|
+
{item.label}
|
|
380
|
+
</SelectItem>
|
|
381
|
+
))}
|
|
382
|
+
</SelectContent>
|
|
383
|
+
</Select>
|
|
384
|
+
{footer}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
<div className='min-h-0 flex-1 overflow-y-auto px-2 py-3'>{content}</div>
|
|
388
|
+
<p className='hidden shrink-0 items-center gap-3 border-t px-4 py-2 text-xs text-muted-foreground lg:flex'>
|
|
389
|
+
<span className='inline-flex items-center gap-1'>
|
|
390
|
+
<Kbd>J</Kbd>
|
|
391
|
+
<Kbd>K</Kbd>
|
|
392
|
+
{t('inbox.keyMove', { defaultValue: 'Move' })}
|
|
393
|
+
</span>
|
|
394
|
+
<span className='inline-flex items-center gap-1'>
|
|
395
|
+
<Kbd>Enter</Kbd>
|
|
396
|
+
{t('inbox.keyOpen', { defaultValue: 'Open' })}
|
|
397
|
+
</span>
|
|
398
|
+
</p>
|
|
399
|
+
</>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function ListEmpty({
|
|
404
|
+
title,
|
|
405
|
+
description,
|
|
406
|
+
action,
|
|
407
|
+
}: {
|
|
408
|
+
readonly title: string;
|
|
409
|
+
readonly description: string;
|
|
410
|
+
readonly action?: ReactNode;
|
|
411
|
+
}): ReactElement {
|
|
412
|
+
return (
|
|
413
|
+
<Empty className='mx-1 min-h-48'>
|
|
414
|
+
<EmptyHeader>
|
|
415
|
+
<EmptyMedia variant='icon'>
|
|
416
|
+
<InboxIcon />
|
|
417
|
+
</EmptyMedia>
|
|
418
|
+
<EmptyTitle>{title}</EmptyTitle>
|
|
419
|
+
<EmptyDescription>{description}</EmptyDescription>
|
|
420
|
+
</EmptyHeader>
|
|
421
|
+
{action ? <EmptyContent>{action}</EmptyContent> : null}
|
|
422
|
+
</Empty>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A group of the list, followed by what comes right after it. */
|
|
427
|
+
function CollectionSlot({
|
|
428
|
+
children,
|
|
429
|
+
}: {
|
|
430
|
+
readonly children: ReactNode;
|
|
431
|
+
}): ReactElement {
|
|
432
|
+
return <>{children}</>;
|
|
433
|
+
}
|