@gntik-ai/templates 0.0.0-stage → 0.5.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/dist/index.d.ts +2420 -0
- package/dist/index.js +14520 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -3
- package/src/accept-invitation/Page.test.tsx +40 -0
- package/src/accept-invitation/Page.tsx +153 -0
- package/src/accept-invitation/data.ts +22 -0
- package/src/accept-invitation/template.meta.ts +12 -0
- package/src/assistant-chat/Page.test.tsx +51 -0
- package/src/assistant-chat/Page.tsx +273 -0
- package/src/assistant-chat/data.ts +71 -0
- package/src/assistant-chat/template.meta.ts +12 -0
- package/src/audit-log/Page.test.tsx +58 -0
- package/src/audit-log/Page.tsx +284 -0
- package/src/audit-log/data.ts +133 -0
- package/src/audit-log/template.meta.ts +12 -0
- package/src/board/Lane.tsx +117 -0
- package/src/board/Page.test.tsx +68 -0
- package/src/board/Page.tsx +178 -0
- package/src/board/data.ts +127 -0
- package/src/board/template.meta.ts +12 -0
- package/src/board/useBoard.ts +146 -0
- package/src/changelog/Page.test.tsx +31 -0
- package/src/changelog/Page.tsx +174 -0
- package/src/changelog/data.ts +105 -0
- package/src/changelog/template.meta.ts +12 -0
- package/src/code-editor/Page.test.tsx +83 -0
- package/src/code-editor/Page.tsx +207 -0
- package/src/code-editor/data.ts +130 -0
- package/src/code-editor/template.meta.ts +12 -0
- package/src/contact/Page.test.tsx +33 -0
- package/src/contact/Page.tsx +272 -0
- package/src/contact/PublicFrame.tsx +84 -0
- package/src/contact/data.ts +129 -0
- package/src/contact/template.meta.ts +12 -0
- package/src/create-wizard/Overlay.test.tsx +68 -0
- package/src/create-wizard/Page.test.tsx +57 -0
- package/src/create-wizard/Page.tsx +246 -0
- package/src/create-wizard/data.ts +139 -0
- package/src/create-wizard/examples/Overlay.tsx +60 -0
- package/src/create-wizard/template.meta.ts +12 -0
- package/src/create-workspace/Page.test.tsx +41 -0
- package/src/create-workspace/Page.tsx +227 -0
- package/src/create-workspace/data.ts +61 -0
- package/src/create-workspace/template.meta.ts +12 -0
- package/src/data-explorer/Page.test.tsx +45 -0
- package/src/data-explorer/Page.tsx +247 -0
- package/src/data-explorer/data.ts +94 -0
- package/src/data-explorer/template.meta.ts +12 -0
- package/src/docs-article/ArticleFeedback.tsx +75 -0
- package/src/docs-article/Page.test.tsx +34 -0
- package/src/docs-article/Page.tsx +200 -0
- package/src/docs-article/data.ts +65 -0
- package/src/docs-article/template.meta.ts +12 -0
- package/src/evaluations/Page.test.tsx +40 -0
- package/src/evaluations/Page.tsx +238 -0
- package/src/evaluations/data.ts +194 -0
- package/src/evaluations/template.meta.ts +12 -0
- package/src/forgot-password/Page.test.tsx +199 -0
- package/src/forgot-password/Page.tsx +66 -0
- package/src/forgot-password/data.ts +5 -0
- package/src/forgot-password/examples/UsernameOrEmail.tsx +25 -0
- package/src/forgot-password/template.meta.ts +12 -0
- package/src/getting-started/Page.test.tsx +27 -0
- package/src/getting-started/Page.tsx +125 -0
- package/src/getting-started/data.ts +67 -0
- package/src/getting-started/template.meta.ts +12 -0
- package/src/help-home/Page.test.tsx +33 -0
- package/src/help-home/Page.tsx +159 -0
- package/src/help-home/data.ts +151 -0
- package/src/help-home/template.meta.ts +12 -0
- package/src/home-dashboard/Page.test.tsx +57 -0
- package/src/home-dashboard/Page.tsx +152 -0
- package/src/home-dashboard/data.ts +66 -0
- package/src/home-dashboard/template.meta.ts +12 -0
- package/src/incident-board/Page.test.tsx +77 -0
- package/src/incident-board/Page.tsx +263 -0
- package/src/incident-board/data.ts +220 -0
- package/src/incident-board/template.meta.ts +12 -0
- package/src/incident-console/Page.test.tsx +49 -0
- package/src/incident-console/Page.tsx +274 -0
- package/src/incident-console/data.ts +176 -0
- package/src/incident-console/template.meta.ts +12 -0
- package/src/index.ts +250 -0
- package/src/keyboard-shortcuts/Page.test.tsx +45 -0
- package/src/keyboard-shortcuts/Page.tsx +94 -0
- package/src/keyboard-shortcuts/ShortcutSheet.tsx +80 -0
- package/src/keyboard-shortcuts/data.ts +84 -0
- package/src/keyboard-shortcuts/template.meta.ts +12 -0
- package/src/landing/Page.test.tsx +27 -0
- package/src/landing/Page.tsx +110 -0
- package/src/landing/data.ts +17 -0
- package/src/landing/template.meta.ts +11 -0
- package/src/live-event-stream/Page.test.tsx +251 -0
- package/src/live-event-stream/Page.tsx +307 -0
- package/src/live-event-stream/data.ts +44 -0
- package/src/live-event-stream/examples/Connecting.tsx +5 -0
- package/src/live-event-stream/examples/Error.tsx +5 -0
- package/src/live-event-stream/examples/LiveEmpty.tsx +5 -0
- package/src/live-event-stream/examples/LiveWithEvents.tsx +6 -0
- package/src/live-event-stream/examples/Stopped.tsx +6 -0
- package/src/live-event-stream/template.meta.ts +74 -0
- package/src/messaging/Page.test.tsx +34 -0
- package/src/messaging/Page.tsx +236 -0
- package/src/messaging/data.ts +118 -0
- package/src/messaging/template.meta.ts +12 -0
- package/src/meta.ts +24 -0
- package/src/mfa/Page.test.tsx +22 -0
- package/src/mfa/Page.tsx +45 -0
- package/src/mfa/data.ts +6 -0
- package/src/mfa/template.meta.ts +12 -0
- package/src/model-providers/Page.test.tsx +58 -0
- package/src/model-providers/Page.tsx +198 -0
- package/src/model-providers/data.ts +101 -0
- package/src/model-providers/template.meta.ts +12 -0
- package/src/notification-inbox/Page.test.tsx +49 -0
- package/src/notification-inbox/Page.tsx +237 -0
- package/src/notification-inbox/data.ts +129 -0
- package/src/notification-inbox/template.meta.ts +12 -0
- package/src/pending-activation/Page.test.tsx +390 -0
- package/src/pending-activation/Page.tsx +145 -0
- package/src/pending-activation/data.ts +25 -0
- package/src/pending-activation/template.meta.ts +12 -0
- package/src/pricing/ComparisonTable.tsx +75 -0
- package/src/pricing/Page.test.tsx +29 -0
- package/src/pricing/Page.tsx +83 -0
- package/src/pricing/PublicFrame.tsx +84 -0
- package/src/pricing/data.ts +104 -0
- package/src/pricing/template.meta.ts +12 -0
- package/src/prompt-playground/Page.test.tsx +72 -0
- package/src/prompt-playground/Page.tsx +254 -0
- package/src/prompt-playground/data.ts +68 -0
- package/src/prompt-playground/template.meta.ts +12 -0
- package/src/public-hub/Page.test.tsx +176 -0
- package/src/public-hub/Page.tsx +125 -0
- package/src/public-hub/Source.test.ts +54 -0
- package/src/public-hub/data.ts +24 -0
- package/src/public-hub/examples/Dark.tsx +11 -0
- package/src/public-hub/examples/Full.tsx +6 -0
- package/src/public-hub/examples/HighContrast.tsx +11 -0
- package/src/public-hub/examples/Light.tsx +11 -0
- package/src/public-hub/examples/Loading.tsx +6 -0
- package/src/public-hub/examples/Minimal.tsx +5 -0
- package/src/public-hub/examples/MinimalLoading.tsx +5 -0
- package/src/public-hub/template.meta.ts +60 -0
- package/src/reset-password/Page.test.tsx +27 -0
- package/src/reset-password/Page.tsx +46 -0
- package/src/reset-password/data.ts +5 -0
- package/src/reset-password/template.meta.ts +12 -0
- package/src/resource-detail/Page.test.tsx +260 -0
- package/src/resource-detail/Page.tsx +262 -0
- package/src/resource-detail/data.ts +55 -0
- package/src/resource-detail/examples/postgres-table-detail.tsx +28 -0
- package/src/resource-detail/examples/realtime-configuration.tsx +26 -0
- package/src/resource-detail/examples/runtime-with-transition.tsx +30 -0
- package/src/resource-detail/examples/runtime-without-transition.tsx +33 -0
- package/src/resource-detail/examples/shell.ts +12 -0
- package/src/resource-detail/template.meta.ts +21 -0
- package/src/resource-form/Page.test.tsx +40 -0
- package/src/resource-form/Page.tsx +186 -0
- package/src/resource-form/data.ts +63 -0
- package/src/resource-form/template.meta.ts +12 -0
- package/src/resource-gallery/Page.test.tsx +42 -0
- package/src/resource-gallery/Page.tsx +125 -0
- package/src/resource-gallery/data.ts +44 -0
- package/src/resource-gallery/template.meta.ts +20 -0
- package/src/resource-index/Page.test.tsx +120 -0
- package/src/resource-index/Page.tsx +216 -0
- package/src/resource-index/data.ts +84 -0
- package/src/resource-index/template.meta.ts +21 -0
- package/src/resource-tree/Page.test.tsx +31 -0
- package/src/resource-tree/Page.tsx +232 -0
- package/src/resource-tree/data.ts +180 -0
- package/src/resource-tree/template.meta.ts +12 -0
- package/src/run-trace/Page.test.tsx +56 -0
- package/src/run-trace/Page.tsx +242 -0
- package/src/run-trace/data.ts +171 -0
- package/src/run-trace/template.meta.ts +12 -0
- package/src/scorecard/Page.test.tsx +38 -0
- package/src/scorecard/Page.tsx +160 -0
- package/src/scorecard/TargetsTable.tsx +92 -0
- package/src/scorecard/data.ts +118 -0
- package/src/scorecard/template.meta.ts +12 -0
- package/src/search-results/Page.test.tsx +43 -0
- package/src/search-results/Page.tsx +243 -0
- package/src/search-results/data.ts +130 -0
- package/src/search-results/template.meta.ts +12 -0
- package/src/settings-api-keys/Page.test.tsx +39 -0
- package/src/settings-api-keys/Page.tsx +171 -0
- package/src/settings-api-keys/data.ts +58 -0
- package/src/settings-api-keys/template.meta.ts +12 -0
- package/src/settings-appearance/Page.test.tsx +44 -0
- package/src/settings-appearance/Page.tsx +92 -0
- package/src/settings-appearance/data.ts +21 -0
- package/src/settings-appearance/template.meta.ts +11 -0
- package/src/settings-billing/Page.test.tsx +31 -0
- package/src/settings-billing/Page.tsx +71 -0
- package/src/settings-billing/data.ts +15 -0
- package/src/settings-billing/template.meta.ts +19 -0
- package/src/settings-integrations/Page.test.tsx +31 -0
- package/src/settings-integrations/Page.tsx +185 -0
- package/src/settings-integrations/data.ts +116 -0
- package/src/settings-integrations/template.meta.ts +11 -0
- package/src/settings-members/Page.test.tsx +32 -0
- package/src/settings-members/Page.tsx +140 -0
- package/src/settings-members/data.ts +28 -0
- package/src/settings-members/template.meta.ts +12 -0
- package/src/settings-notifications/Page.test.tsx +31 -0
- package/src/settings-notifications/Page.tsx +71 -0
- package/src/settings-notifications/data.ts +12 -0
- package/src/settings-notifications/template.meta.ts +11 -0
- package/src/settings-profile/Baseline.test.tsx +9 -0
- package/src/settings-profile/Editable.test.tsx +21 -0
- package/src/settings-profile/Page.test.tsx +41 -0
- package/src/settings-profile/Page.tsx +133 -0
- package/src/settings-profile/ReadOnly.test.tsx +143 -0
- package/src/settings-profile/ReadOnlyProfile.tsx +85 -0
- package/src/settings-profile/SettingsFrame.test.tsx +55 -0
- package/src/settings-profile/SettingsFrame.tsx +88 -0
- package/src/settings-profile/__tests__/dom.ts +8 -0
- package/src/settings-profile/data.ts +41 -0
- package/src/settings-profile/examples/ReadOnly.tsx +31 -0
- package/src/settings-profile/fixtures/editable.snap +1 -0
- package/src/settings-profile/settings-nav.ts +98 -0
- package/src/settings-profile/template.meta.ts +21 -0
- package/src/settings-security/MfaSetupDialog.tsx +96 -0
- package/src/settings-security/Page.test.tsx +44 -0
- package/src/settings-security/Page.tsx +158 -0
- package/src/settings-security/data.ts +11 -0
- package/src/settings-security/template.meta.ts +11 -0
- package/src/settings-workspace/Page.test.tsx +44 -0
- package/src/settings-workspace/Page.tsx +120 -0
- package/src/settings-workspace/data.ts +44 -0
- package/src/settings-workspace/template.meta.ts +19 -0
- package/src/shared/ConsoleShell.test.tsx +81 -0
- package/src/shared/ConsoleShell.tsx +98 -0
- package/src/shell-sidebar/Page.test.tsx +21 -0
- package/src/shell-sidebar/Page.tsx +85 -0
- package/src/shell-sidebar/data.ts +24 -0
- package/src/shell-sidebar/template.meta.ts +12 -0
- package/src/shell-topnav/Page.test.tsx +25 -0
- package/src/shell-topnav/Page.tsx +94 -0
- package/src/shell-topnav/data.ts +25 -0
- package/src/shell-topnav/template.meta.ts +12 -0
- package/src/sign-in/BrandPanel.tsx +34 -0
- package/src/sign-in/Page.test.tsx +41 -0
- package/src/sign-in/Page.tsx +73 -0
- package/src/sign-in/data.ts +11 -0
- package/src/sign-in/template.meta.ts +12 -0
- package/src/sign-in-card/Page.test.tsx +182 -0
- package/src/sign-in-card/Page.tsx +82 -0
- package/src/sign-in-card/data.ts +8 -0
- package/src/sign-in-card/examples/Username.tsx +28 -0
- package/src/sign-in-card/template.meta.ts +12 -0
- package/src/sign-up/Page.test.tsx +33 -0
- package/src/sign-up/Page.tsx +70 -0
- package/src/sign-up/data.ts +15 -0
- package/src/sign-up/template.meta.ts +12 -0
- package/src/sso-login/Page.test.tsx +41 -0
- package/src/sso-login/Page.tsx +151 -0
- package/src/sso-login/data.ts +10 -0
- package/src/sso-login/template.meta.ts +12 -0
- package/src/status-403/Page.test.tsx +23 -0
- package/src/status-403/Page.tsx +92 -0
- package/src/status-403/data.ts +13 -0
- package/src/status-403/template.meta.ts +12 -0
- package/src/status-404/Page.test.tsx +23 -0
- package/src/status-404/Page.tsx +60 -0
- package/src/status-404/PageHeading.test.tsx +52 -0
- package/src/status-404/data.ts +12 -0
- package/src/status-404/examples/PageHeader.tsx +28 -0
- package/src/status-404/template.meta.ts +12 -0
- package/src/status-500/Page.test.tsx +24 -0
- package/src/status-500/Page.tsx +70 -0
- package/src/status-500/data.ts +20 -0
- package/src/status-500/template.meta.ts +12 -0
- package/src/status-maintenance/Page.test.tsx +24 -0
- package/src/status-maintenance/Page.tsx +84 -0
- package/src/status-maintenance/data.ts +14 -0
- package/src/status-maintenance/template.meta.ts +12 -0
- package/src/styles.css +11 -0
- package/src/test/a11y.ts +18 -0
- package/src/test/setup.ts +36 -0
- package/src/upgrade-required/Page.test.tsx +28 -0
- package/src/upgrade-required/Page.tsx +79 -0
- package/src/upgrade-required/data.ts +19 -0
- package/src/upgrade-required/template.meta.ts +12 -0
- package/src/usage-analytics/Page.test.tsx +83 -0
- package/src/usage-analytics/Page.tsx +184 -0
- package/src/usage-analytics/data.ts +161 -0
- package/src/usage-analytics/template.meta.ts +21 -0
- package/src/verify-email/Page.test.tsx +32 -0
- package/src/verify-email/Page.tsx +118 -0
- package/src/verify-email/data.ts +7 -0
- package/src/verify-email/template.meta.ts +12 -0
- package/src/workflow-builder/Page.test.tsx +97 -0
- package/src/workflow-builder/Page.tsx +163 -0
- package/src/workflow-builder/data.ts +13 -0
- package/src/workflow-builder/template.meta.ts +12 -0
- package/README.md +0 -3
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { ModelKeysList, type ModelKey } from '@gntik-ai/blocks';
|
|
2
|
+
import { Plus } from '@gntik-ai/icons';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogBody,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
Field,
|
|
13
|
+
FieldDescription,
|
|
14
|
+
FieldLabel,
|
|
15
|
+
Input,
|
|
16
|
+
Meter,
|
|
17
|
+
PasswordInput,
|
|
18
|
+
Section,
|
|
19
|
+
SimpleSelect,
|
|
20
|
+
Stack,
|
|
21
|
+
} from '@gntik-ai/ui';
|
|
22
|
+
import { useState, type FormEvent } from 'react';
|
|
23
|
+
import { SettingsFrame, type SettingsFrameOptions } from '../settings-profile/SettingsFrame';
|
|
24
|
+
import {
|
|
25
|
+
maskSecret,
|
|
26
|
+
modelProvidersNavItem,
|
|
27
|
+
providerKeys,
|
|
28
|
+
providerOptions,
|
|
29
|
+
providerUsageLimits,
|
|
30
|
+
type NewModelKeyInput,
|
|
31
|
+
type ProviderUsageLimit,
|
|
32
|
+
} from './data';
|
|
33
|
+
|
|
34
|
+
export interface ModelProvidersProps extends SettingsFrameOptions {
|
|
35
|
+
keys: ModelKey[];
|
|
36
|
+
limits: ProviderUsageLimit[];
|
|
37
|
+
providers: Array<{ value: string; label: string }>;
|
|
38
|
+
/** Tests a key (the "Test" button); resolve true when the provider answered. */
|
|
39
|
+
onTest: (key: ModelKey) => Promise<boolean> | boolean;
|
|
40
|
+
onRevoke: (key: ModelKey) => void;
|
|
41
|
+
/** Stores a new key and returns it masked. Defaults to a local, untested entry. */
|
|
42
|
+
onAddKey: (input: NewModelKeyInput) => ModelKey | Promise<ModelKey>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const EMPTY: NewModelKeyInput = { name: '', provider: '', endpoint: '', secret: '' };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Model provider settings: SettingsFrame (shared settings nav + a "Model providers" item) with
|
|
49
|
+
* the ModelKeysList (masked keys, test connection, revoke), an add-key dialog and the usage
|
|
50
|
+
* limits as Meters.
|
|
51
|
+
*/
|
|
52
|
+
export default function ModelProvidersPage({
|
|
53
|
+
keys: initialKeys = providerKeys,
|
|
54
|
+
limits = providerUsageLimits,
|
|
55
|
+
providers = providerOptions,
|
|
56
|
+
onTest,
|
|
57
|
+
onRevoke,
|
|
58
|
+
onAddKey,
|
|
59
|
+
extraNavItems = [],
|
|
60
|
+
...frame
|
|
61
|
+
}: Partial<ModelProvidersProps>) {
|
|
62
|
+
const [keys, setKeys] = useState(initialKeys);
|
|
63
|
+
const [open, setOpen] = useState(false);
|
|
64
|
+
const [draft, setDraft] = useState(EMPTY);
|
|
65
|
+
const [submitted, setSubmitted] = useState(false);
|
|
66
|
+
const [saving, setSaving] = useState(false);
|
|
67
|
+
const needsEndpoint = draft.provider === 'openai-compatible' || draft.provider === 'self-hosted';
|
|
68
|
+
const invalid = {
|
|
69
|
+
name: !draft.name.trim(),
|
|
70
|
+
provider: !draft.provider,
|
|
71
|
+
endpoint: needsEndpoint && !/^https?:\/\/\S+$/.test(draft.endpoint.trim()),
|
|
72
|
+
secret: draft.secret.trim().length < 8,
|
|
73
|
+
};
|
|
74
|
+
const set = (key: keyof NewModelKeyInput) => (value: string | null) =>
|
|
75
|
+
setDraft((d) => ({ ...d, [key]: value ?? '' }));
|
|
76
|
+
|
|
77
|
+
const openDialog = () => {
|
|
78
|
+
setDraft(EMPTY);
|
|
79
|
+
setSubmitted(false);
|
|
80
|
+
setOpen(true);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const submit = async (event: FormEvent) => {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
setSubmitted(true);
|
|
86
|
+
if (Object.values(invalid).some(Boolean)) return;
|
|
87
|
+
setSaving(true);
|
|
88
|
+
try {
|
|
89
|
+
const input = { ...draft, endpoint: needsEndpoint ? draft.endpoint.trim() : '' };
|
|
90
|
+
const created = onAddKey
|
|
91
|
+
? await onAddKey(input)
|
|
92
|
+
: {
|
|
93
|
+
id: `key-${keys.length + 1}`,
|
|
94
|
+
name: input.name.trim(),
|
|
95
|
+
provider: input.provider,
|
|
96
|
+
endpoint: input.endpoint || undefined,
|
|
97
|
+
maskedKey: maskSecret(input.secret),
|
|
98
|
+
status: 'untested' as const,
|
|
99
|
+
};
|
|
100
|
+
setKeys((list) => [...list, created]);
|
|
101
|
+
setOpen(false);
|
|
102
|
+
} finally {
|
|
103
|
+
setSaving(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<SettingsFrame
|
|
109
|
+
page="model-providers"
|
|
110
|
+
extraNavItems={[modelProvidersNavItem, ...extraNavItems.filter((i) => i.id !== modelProvidersNavItem.id)]}
|
|
111
|
+
description="Connect the model providers your assistants call. Keys are encrypted at rest and only shown masked."
|
|
112
|
+
actions={[{ label: 'Add provider key', icon: Plus, variant: 'primary', onClick: openDialog }]}
|
|
113
|
+
{...frame}
|
|
114
|
+
>
|
|
115
|
+
<Stack gap={6}>
|
|
116
|
+
<ModelKeysList titleAs="h2" title="Provider keys" keys={keys} onTest={onTest} onRevoke={onRevoke} />
|
|
117
|
+
<Section
|
|
118
|
+
variant="card"
|
|
119
|
+
headingLevel="h2"
|
|
120
|
+
title="Usage limits"
|
|
121
|
+
description="Workspace-wide caps across every provider key."
|
|
122
|
+
>
|
|
123
|
+
<Stack gap={5}>
|
|
124
|
+
{limits.map((l) => (
|
|
125
|
+
<Meter
|
|
126
|
+
key={l.id}
|
|
127
|
+
label={l.label}
|
|
128
|
+
value={l.value}
|
|
129
|
+
max={l.max}
|
|
130
|
+
valueLabel={l.valueLabel}
|
|
131
|
+
aria-valuetext={l.valueLabel}
|
|
132
|
+
note={l.note}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</Stack>
|
|
136
|
+
</Section>
|
|
137
|
+
</Stack>
|
|
138
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
139
|
+
<DialogContent>
|
|
140
|
+
<form onSubmit={(e) => void submit(e)} noValidate>
|
|
141
|
+
<DialogHeader>
|
|
142
|
+
<DialogTitle>Add provider key</DialogTitle>
|
|
143
|
+
<DialogDescription>The key is stored encrypted; you will not be able to see it again.</DialogDescription>
|
|
144
|
+
</DialogHeader>
|
|
145
|
+
<DialogBody>
|
|
146
|
+
<Stack gap={4}>
|
|
147
|
+
<Field invalid={submitted && invalid.name}>
|
|
148
|
+
<FieldLabel required>Name</FieldLabel>
|
|
149
|
+
<Input value={draft.name} onValueChange={set('name')} placeholder="Production" />
|
|
150
|
+
</Field>
|
|
151
|
+
<SimpleSelect
|
|
152
|
+
label="Provider"
|
|
153
|
+
items={providers}
|
|
154
|
+
value={draft.provider || null}
|
|
155
|
+
onValueChange={set('provider')}
|
|
156
|
+
placeholder="Choose a provider…"
|
|
157
|
+
/>
|
|
158
|
+
{needsEndpoint && (
|
|
159
|
+
<Field invalid={submitted && invalid.endpoint}>
|
|
160
|
+
<FieldLabel required>Endpoint URL</FieldLabel>
|
|
161
|
+
<Input
|
|
162
|
+
type="url"
|
|
163
|
+
value={draft.endpoint}
|
|
164
|
+
onValueChange={set('endpoint')}
|
|
165
|
+
placeholder="https://llm-gateway.internal/v1"
|
|
166
|
+
className="font-mono"
|
|
167
|
+
/>
|
|
168
|
+
</Field>
|
|
169
|
+
)}
|
|
170
|
+
<Field invalid={submitted && invalid.secret}>
|
|
171
|
+
<FieldLabel required>API key</FieldLabel>
|
|
172
|
+
<PasswordInput
|
|
173
|
+
value={draft.secret}
|
|
174
|
+
onValueChange={set('secret')}
|
|
175
|
+
autoComplete="off"
|
|
176
|
+
className="font-mono"
|
|
177
|
+
/>
|
|
178
|
+
<FieldDescription>At least 8 characters. Test the connection after adding it.</FieldDescription>
|
|
179
|
+
</Field>
|
|
180
|
+
{submitted && invalid.provider && (
|
|
181
|
+
<p className="text-[12.5px] text-destructive-text">Choose a provider.</p>
|
|
182
|
+
)}
|
|
183
|
+
</Stack>
|
|
184
|
+
</DialogBody>
|
|
185
|
+
<DialogFooter>
|
|
186
|
+
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
187
|
+
Cancel
|
|
188
|
+
</Button>
|
|
189
|
+
<Button type="submit" loading={saving}>
|
|
190
|
+
Add key
|
|
191
|
+
</Button>
|
|
192
|
+
</DialogFooter>
|
|
193
|
+
</form>
|
|
194
|
+
</DialogContent>
|
|
195
|
+
</Dialog>
|
|
196
|
+
</SettingsFrame>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ModelKey } from '@gntik-ai/blocks';
|
|
2
|
+
import { Cpu } from '@gntik-ai/icons';
|
|
3
|
+
import type { SettingsNavExtraItem } from '../settings-profile/settings-nav';
|
|
4
|
+
|
|
5
|
+
/** Nav entry this page adds to the shared settings nav (id `model-providers`). */
|
|
6
|
+
export const modelProvidersNavItem: SettingsNavExtraItem = {
|
|
7
|
+
id: 'model-providers',
|
|
8
|
+
label: 'Model providers',
|
|
9
|
+
icon: Cpu,
|
|
10
|
+
group: 'Workspace',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** A usage limit shown as a Meter (monthly tokens, spend, rate). */
|
|
14
|
+
export interface ProviderUsageLimit {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
value: number;
|
|
18
|
+
max: number;
|
|
19
|
+
/** Visible value text, e.g. "31.2M / 50M tokens". */
|
|
20
|
+
valueLabel: string;
|
|
21
|
+
/** Helper line under the bar. */
|
|
22
|
+
note?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the add-key dialog collects; the secret never comes back to the UI. */
|
|
26
|
+
export interface NewModelKeyInput {
|
|
27
|
+
name: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
endpoint: string;
|
|
30
|
+
secret: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const providerOptions = [
|
|
34
|
+
{ value: 'Provider A', label: 'Provider A' },
|
|
35
|
+
{ value: 'Provider B', label: 'Provider B' },
|
|
36
|
+
{ value: 'openai-compatible', label: 'OpenAI-compatible endpoint' },
|
|
37
|
+
{ value: 'self-hosted', label: 'Self-hosted' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export const providerKeys: ModelKey[] = [
|
|
41
|
+
{
|
|
42
|
+
id: 'k1',
|
|
43
|
+
name: 'Production',
|
|
44
|
+
provider: 'Provider A',
|
|
45
|
+
maskedKey: 'pa-…4f2a',
|
|
46
|
+
status: 'connected',
|
|
47
|
+
lastUsed: '3 min ago',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'k2',
|
|
51
|
+
name: 'Evaluations',
|
|
52
|
+
provider: 'Provider B',
|
|
53
|
+
maskedKey: 'pb-…a913',
|
|
54
|
+
status: 'connected',
|
|
55
|
+
lastUsed: '1 h ago',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'k3',
|
|
59
|
+
name: 'Gateway',
|
|
60
|
+
provider: 'openai-compatible',
|
|
61
|
+
endpoint: 'https://llm-gateway.internal/v1',
|
|
62
|
+
maskedKey: 'sk-…9c01',
|
|
63
|
+
status: 'untested',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'k4',
|
|
67
|
+
name: 'On-prem cluster',
|
|
68
|
+
provider: 'self-hosted',
|
|
69
|
+
endpoint: 'http://10.0.4.12:8000/v1',
|
|
70
|
+
maskedKey: 'tok-…77de',
|
|
71
|
+
status: 'failing',
|
|
72
|
+
lastUsed: '2 days ago',
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export const providerUsageLimits: ProviderUsageLimit[] = [
|
|
77
|
+
{
|
|
78
|
+
id: 'tokens',
|
|
79
|
+
label: 'Tokens this month',
|
|
80
|
+
value: 31_200_000,
|
|
81
|
+
max: 50_000_000,
|
|
82
|
+
valueLabel: '31.2M / 50M',
|
|
83
|
+
note: 'Resets on the 1st of each month.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'spend',
|
|
87
|
+
label: 'Spend this month',
|
|
88
|
+
value: 412,
|
|
89
|
+
max: 500,
|
|
90
|
+
valueLabel: '$412 / $500',
|
|
91
|
+
note: 'Requests are blocked when the cap is reached.',
|
|
92
|
+
},
|
|
93
|
+
{ id: 'rate', label: 'Peak requests per minute', value: 540, max: 600, valueLabel: '540 / 600 rpm' },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** Masks a secret for display: provider prefix + last four characters. */
|
|
97
|
+
export function maskSecret(secret: string) {
|
|
98
|
+
const trimmed = secret.trim();
|
|
99
|
+
const prefix = trimmed.match(/^[a-z]+-/i)?.[0] ?? '';
|
|
100
|
+
return `${prefix}…${trimmed.slice(-4)}`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TemplateMeta } from '../meta';
|
|
2
|
+
|
|
3
|
+
export const meta: TemplateMeta = {
|
|
4
|
+
name: 'Model providers settings',
|
|
5
|
+
family: 'AI',
|
|
6
|
+
priority: 'P3',
|
|
7
|
+
status: 'experimental',
|
|
8
|
+
description:
|
|
9
|
+
'Settings page (shared settings nav plus a "Model providers" item) with the ModelKeysList — masked keys, test connection, confirmed revoke — an add-key dialog (provider, endpoint for compatible / self-hosted, secret) and workspace usage limits as Meters.',
|
|
10
|
+
layout: 'SettingsLayout',
|
|
11
|
+
blocks: ['app-sidebar', 'app-topbar', 'page-header', 'model-keys-list'],
|
|
12
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
4
|
+
import { LinkProvider } from '@gntik-ai/ui';
|
|
5
|
+
import type { ComponentPropsWithRef } from 'react';
|
|
6
|
+
import NotificationInboxPage from './Page';
|
|
7
|
+
|
|
8
|
+
describe('NotificationInboxPage', () => {
|
|
9
|
+
it('renders the console, the inbox list and the empty detail', { timeout: 15000 }, async () => {
|
|
10
|
+
render(<NotificationInboxPage />);
|
|
11
|
+
expect(screen.getByRole('main')).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument();
|
|
13
|
+
const list = screen.getByRole('list', { name: 'Notifications' });
|
|
14
|
+
expect(within(list).getAllByRole('button')).toHaveLength(6);
|
|
15
|
+
expect(screen.getByRole('heading', { name: 'Select a notification' })).toBeInTheDocument();
|
|
16
|
+
await expectNoAxeViolations();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('opens a notification, marks it read and filters by unread', { timeout: 15000 }, async () => {
|
|
20
|
+
const onReadChange = vi.fn();
|
|
21
|
+
render(<NotificationInboxPage onReadChange={onReadChange} />);
|
|
22
|
+
const list = screen.getByRole('list', { name: 'Notifications' });
|
|
23
|
+
await userEvent.click(within(list).getByRole('button', { name: /Budget alert/ }));
|
|
24
|
+
expect(screen.getByRole('heading', { level: 2, name: 'Budget alert: api-gateway at 97%' })).toBeInTheDocument();
|
|
25
|
+
expect(onReadChange).toHaveBeenCalledWith('ntf_01', true);
|
|
26
|
+
|
|
27
|
+
await userEvent.click(screen.getByRole('button', { name: /^Unread/ }));
|
|
28
|
+
const unread = screen.getByRole('list', { name: 'Notifications' });
|
|
29
|
+
expect(within(unread).getAllByRole('button')).toHaveLength(2);
|
|
30
|
+
|
|
31
|
+
await userEvent.click(screen.getAllByRole('button', { name: 'Mark all as read' })[0]!);
|
|
32
|
+
expect(screen.getByRole('heading', { name: 'You’re all caught up' })).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('Open goes through the LinkProvider router link', { timeout: 15000 }, async () => {
|
|
36
|
+
const RouterLink = ({ href, ...props }: ComponentPropsWithRef<'a'>) => <a data-router="" href={href} {...props} />;
|
|
37
|
+
render(
|
|
38
|
+
<LinkProvider component={RouterLink}>
|
|
39
|
+
<NotificationInboxPage defaultSelectedId="ntf_01" />
|
|
40
|
+
</LinkProvider>,
|
|
41
|
+
);
|
|
42
|
+
const list = screen.getByRole('list', { name: 'Notifications' });
|
|
43
|
+
await userEvent.click(within(list).getByRole('button', { name: /Budget alert/ }));
|
|
44
|
+
const open = screen.getByRole('button', { name: 'Open' });
|
|
45
|
+
expect(open.tagName).toBe('A');
|
|
46
|
+
expect(open).toHaveAttribute('data-router');
|
|
47
|
+
expect(open.getAttribute('href')).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { PageHeader } from '@gntik-ai/blocks';
|
|
2
|
+
import { Archive, BellOff, CheckCheck, ExternalLink, MailOpen } from '@gntik-ai/icons';
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Link,
|
|
8
|
+
Page,
|
|
9
|
+
SplitLayout,
|
|
10
|
+
StatusDot,
|
|
11
|
+
Timestamp,
|
|
12
|
+
Toggle,
|
|
13
|
+
ToggleGroup,
|
|
14
|
+
cn,
|
|
15
|
+
type BreadcrumbItem,
|
|
16
|
+
useI18n,
|
|
17
|
+
} from '@gntik-ai/ui';
|
|
18
|
+
import { useId, useState } from 'react';
|
|
19
|
+
import { ConsoleShell, type ConsoleShellProps } from '../shared/ConsoleShell';
|
|
20
|
+
import { inboxBreadcrumbs, inboxCategoryLabels, inboxNotifications, type InboxNotification } from './data';
|
|
21
|
+
|
|
22
|
+
export type InboxFilter = 'all' | 'unread' | 'mention';
|
|
23
|
+
|
|
24
|
+
export interface NotificationInboxProps {
|
|
25
|
+
/** Initial notifications, newest first. The page keeps read/archived state locally. */
|
|
26
|
+
notifications: InboxNotification[];
|
|
27
|
+
defaultFilter: InboxFilter;
|
|
28
|
+
/** Initially selected notification (defaults to the first one). */
|
|
29
|
+
defaultSelectedId: string | null;
|
|
30
|
+
onReadChange: (id: string, read: boolean) => void;
|
|
31
|
+
onArchive: (id: string) => void;
|
|
32
|
+
onMarkAllRead: () => void;
|
|
33
|
+
breadcrumbs: BreadcrumbItem[];
|
|
34
|
+
currentHref: string;
|
|
35
|
+
/** ConsoleShell props (app nav, user, workspaces, sidebar footer, topbar…). */
|
|
36
|
+
shell: Omit<ConsoleShellProps, 'children'>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FILTERS: Array<{ value: InboxFilter; label: string }> = [
|
|
40
|
+
{ value: 'all', label: 'All' },
|
|
41
|
+
{ value: 'unread', label: 'Unread' },
|
|
42
|
+
{ value: 'mention', label: 'Mentions' },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const matches = (n: InboxNotification, filter: InboxFilter) =>
|
|
46
|
+
filter === 'all' ? true : filter === 'unread' ? !n.read : n.category === filter;
|
|
47
|
+
|
|
48
|
+
/** Notification inbox: ConsoleShell + Page + SplitLayout (filtered list → detail, Back below lg). */
|
|
49
|
+
export default function NotificationInboxPage(props: Partial<NotificationInboxProps>) {
|
|
50
|
+
const { t } = useI18n();
|
|
51
|
+
const {
|
|
52
|
+
notifications: initial = inboxNotifications,
|
|
53
|
+
defaultFilter = 'all',
|
|
54
|
+
defaultSelectedId,
|
|
55
|
+
onReadChange,
|
|
56
|
+
onArchive,
|
|
57
|
+
onMarkAllRead,
|
|
58
|
+
breadcrumbs = inboxBreadcrumbs,
|
|
59
|
+
currentHref = '/notifications',
|
|
60
|
+
shell,
|
|
61
|
+
} = props;
|
|
62
|
+
const [items, setItems] = useState(initial);
|
|
63
|
+
const [filter, setFilter] = useState<InboxFilter>(defaultFilter);
|
|
64
|
+
const [selectedId, setSelectedId] = useState<string | null>(defaultSelectedId ?? null);
|
|
65
|
+
const [showDetail, setShowDetail] = useState(false);
|
|
66
|
+
const titleId = useId();
|
|
67
|
+
|
|
68
|
+
const visible = items.filter((n) => matches(n, filter));
|
|
69
|
+
const selected = items.find((n) => n.id === selectedId) ?? null;
|
|
70
|
+
const unread = items.filter((n) => !n.read).length;
|
|
71
|
+
|
|
72
|
+
const setRead = (id: string, read: boolean) => {
|
|
73
|
+
setItems((list) => list.map((n) => (n.id === id ? { ...n, read } : n)));
|
|
74
|
+
onReadChange?.(id, read);
|
|
75
|
+
};
|
|
76
|
+
const open = (n: InboxNotification) => {
|
|
77
|
+
setSelectedId(n.id);
|
|
78
|
+
setShowDetail(true);
|
|
79
|
+
if (!n.read) setRead(n.id, true);
|
|
80
|
+
};
|
|
81
|
+
const archive = (id: string) => {
|
|
82
|
+
setItems((list) => list.filter((n) => n.id !== id));
|
|
83
|
+
setSelectedId(null);
|
|
84
|
+
setShowDetail(false);
|
|
85
|
+
onArchive?.(id);
|
|
86
|
+
};
|
|
87
|
+
const markAllRead = () => {
|
|
88
|
+
setItems((list) => list.map((n) => ({ ...n, read: true })));
|
|
89
|
+
onMarkAllRead?.();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const list =
|
|
93
|
+
visible.length === 0 ? (
|
|
94
|
+
<EmptyState
|
|
95
|
+
icon={BellOff}
|
|
96
|
+
size="sm"
|
|
97
|
+
className="mx-auto mt-8"
|
|
98
|
+
title={filter === 'unread' ? 'You’re all caught up' : 'Nothing here yet'}
|
|
99
|
+
description={filter === 'unread' ? t('notifications.empty') : 'Try another filter.'}
|
|
100
|
+
/>
|
|
101
|
+
) : (
|
|
102
|
+
<ul aria-label={t('common.notifications')} className="flex flex-col gap-0.5 p-2">
|
|
103
|
+
{visible.map((n) => (
|
|
104
|
+
<li key={n.id}>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
aria-current={n.id === selectedId ? 'true' : undefined}
|
|
108
|
+
onClick={() => open(n)}
|
|
109
|
+
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-start hover:bg-accent/55 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring aria-[current=true]:bg-accent"
|
|
110
|
+
>
|
|
111
|
+
<span className="mt-1.5 flex w-2 shrink-0 justify-center">
|
|
112
|
+
{!n.read && <StatusDot tone={n.tone} aria-hidden />}
|
|
113
|
+
</span>
|
|
114
|
+
<span className="min-w-0 flex-1">
|
|
115
|
+
<span className="flex items-baseline gap-2">
|
|
116
|
+
<span
|
|
117
|
+
className={cn('min-w-0 flex-1 truncate text-[13px] text-foreground', !n.read && 'font-semibold')}
|
|
118
|
+
>
|
|
119
|
+
{n.title}
|
|
120
|
+
</span>
|
|
121
|
+
<Timestamp value={n.time} tooltip={false} className="shrink-0 font-mono text-[11px]" />
|
|
122
|
+
</span>
|
|
123
|
+
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">{n.preview}</span>
|
|
124
|
+
{!n.read && <span className="sr-only">Unread</span>}
|
|
125
|
+
</span>
|
|
126
|
+
</button>
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const detail = selected ? (
|
|
133
|
+
<article aria-labelledby={titleId} className="flex flex-col gap-5 p-5 sm:p-6">
|
|
134
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
135
|
+
<Badge tone="neutral" variant="outline">
|
|
136
|
+
{inboxCategoryLabels[selected.category]}
|
|
137
|
+
</Badge>
|
|
138
|
+
<span className="text-[12.5px] text-muted-foreground">
|
|
139
|
+
From <span className="font-medium text-foreground">{selected.from}</span> ·{' '}
|
|
140
|
+
<Timestamp value={selected.time} format="absolute" className="font-mono text-[12px]" />
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
<h2 id={titleId} className="text-[18px] font-semibold tracking-tight text-foreground">
|
|
144
|
+
{selected.title}
|
|
145
|
+
</h2>
|
|
146
|
+
<div className="flex max-w-2xl flex-col gap-3 text-[13.5px] leading-6 text-foreground">
|
|
147
|
+
{selected.body.map((p) => (
|
|
148
|
+
<p key={p}>{p}</p>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
152
|
+
{selected.href && (
|
|
153
|
+
<Button
|
|
154
|
+
trailingIcon={ExternalLink}
|
|
155
|
+
render={<Link href={selected.href} tone="inherit" underline="none" />}
|
|
156
|
+
nativeButton={false}
|
|
157
|
+
>
|
|
158
|
+
Open
|
|
159
|
+
</Button>
|
|
160
|
+
)}
|
|
161
|
+
<Button variant="secondary" icon={MailOpen} onClick={() => setRead(selected.id, false)}>
|
|
162
|
+
Mark as unread
|
|
163
|
+
</Button>
|
|
164
|
+
<Button variant="ghost" icon={Archive} onClick={() => archive(selected.id)}>
|
|
165
|
+
Archive
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</article>
|
|
169
|
+
) : (
|
|
170
|
+
<EmptyState
|
|
171
|
+
icon={MailOpen}
|
|
172
|
+
size="sm"
|
|
173
|
+
titleAs="h2"
|
|
174
|
+
className="mx-auto mt-16"
|
|
175
|
+
title="Select a notification"
|
|
176
|
+
description="Its details show up here."
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<ConsoleShell breadcrumbs={breadcrumbs} currentHref={currentHref} {...shell}>
|
|
182
|
+
<Page
|
|
183
|
+
width="full"
|
|
184
|
+
header={
|
|
185
|
+
<PageHeader
|
|
186
|
+
breadcrumbs={null}
|
|
187
|
+
title="Notifications"
|
|
188
|
+
description="Mentions, deployments, billing and access requests across your workspace."
|
|
189
|
+
status=""
|
|
190
|
+
meta={[]}
|
|
191
|
+
tabs={null}
|
|
192
|
+
actions={[
|
|
193
|
+
{
|
|
194
|
+
label: 'Mark all as read',
|
|
195
|
+
icon: CheckCheck,
|
|
196
|
+
variant: 'secondary',
|
|
197
|
+
disabled: unread === 0,
|
|
198
|
+
onClick: markAllRead,
|
|
199
|
+
},
|
|
200
|
+
]}
|
|
201
|
+
/>
|
|
202
|
+
}
|
|
203
|
+
>
|
|
204
|
+
<div className="h-[640px] min-h-0 overflow-hidden rounded-xl border border-border">
|
|
205
|
+
<SplitLayout
|
|
206
|
+
listLabel="Inbox"
|
|
207
|
+
detailLabel="Notification"
|
|
208
|
+
showDetail={showDetail}
|
|
209
|
+
onShowDetailChange={setShowDetail}
|
|
210
|
+
listHeader={
|
|
211
|
+
<ToggleGroup
|
|
212
|
+
aria-label="Filter notifications"
|
|
213
|
+
size="sm"
|
|
214
|
+
value={[filter]}
|
|
215
|
+
onValueChange={(v) => {
|
|
216
|
+
const next = v[0];
|
|
217
|
+
if (next === 'all' || next === 'unread' || next === 'mention') setFilter(next);
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
{FILTERS.map((f) => (
|
|
221
|
+
<Toggle key={f.value} value={f.value}>
|
|
222
|
+
{f.label}
|
|
223
|
+
{f.value === 'unread' && unread > 0 && (
|
|
224
|
+
<span className="font-mono text-[10.5px] text-muted-foreground">{unread}</span>
|
|
225
|
+
)}
|
|
226
|
+
</Toggle>
|
|
227
|
+
))}
|
|
228
|
+
</ToggleGroup>
|
|
229
|
+
}
|
|
230
|
+
list={list}
|
|
231
|
+
detail={detail}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
</Page>
|
|
235
|
+
</ConsoleShell>
|
|
236
|
+
);
|
|
237
|
+
}
|