@nextclaw/ui 0.5.9 → 0.5.11

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.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-STUSj6p9.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BtwwwWcv.css">
9
+ <script type="module" crossorigin src="/assets/index-BHB8zYn7.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BGzsyzDd.css">
11
11
  </head>
12
12
 
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@ export function MaskedInput({ maskedValue, isSet, className, ...props }: MaskedI
18
18
  <Input
19
19
  type={showKey ? 'text' : 'password'}
20
20
  className={cn('pr-20', className)}
21
- placeholder={isSet ? `${t('apiKeySet')} (Unchanged)` : ''}
21
+ placeholder={isSet ? `${t('apiKeySet')} (${t('unchanged')})` : ''}
22
22
  {...props}
23
23
  />
24
24
  <div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
@@ -11,22 +11,19 @@ interface StatusBadgeProps {
11
11
 
12
12
  const statusConfig: Record<
13
13
  Status,
14
- { label: string; dotClass: string; textClass: string; bgClass: string }
14
+ { dotClass: string; textClass: string; bgClass: string }
15
15
  > = {
16
16
  connected: {
17
- label: t('connected'),
18
17
  dotClass: 'bg-emerald-500',
19
18
  textClass: 'text-emerald-600',
20
19
  bgClass: 'bg-emerald-50',
21
20
  },
22
21
  disconnected: {
23
- label: t('disconnected'),
24
22
  dotClass: 'bg-gray-300',
25
23
  textClass: 'text-gray-400',
26
24
  bgClass: 'bg-gray-100/80',
27
25
  },
28
26
  connecting: {
29
- label: t('connecting'),
30
27
  dotClass: 'bg-amber-400',
31
28
  textClass: 'text-amber-600',
32
29
  bgClass: 'bg-amber-50',
@@ -35,6 +32,7 @@ const statusConfig: Record<
35
32
 
36
33
  export function StatusBadge({ status, className }: StatusBadgeProps) {
37
34
  const config = statusConfig[status];
35
+ const label = status === 'connected' ? t('connected') : status === 'disconnected' ? t('disconnected') : t('connecting');
38
36
 
39
37
  return (
40
38
  <div className={cn(
@@ -44,7 +42,7 @@ export function StatusBadge({ status, className }: StatusBadgeProps) {
44
42
  )}>
45
43
  <span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
46
44
  <span className={cn('text-[11px] font-medium flex items-center gap-1', config.textClass)}>
47
- {config.label}
45
+ {label}
48
46
  {status === 'connecting' && <Loader2 className="h-2.5 w-2.5 animate-spin" />}
49
47
  </span>
50
48
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { X } from 'lucide-react';
3
3
  import { cn } from '@/lib/utils';
4
+ import { t } from '@/lib/i18n';
4
5
 
5
6
  interface TagInputProps {
6
7
  value: string[];
@@ -9,7 +10,7 @@ interface TagInputProps {
9
10
  placeholder?: string;
10
11
  }
11
12
 
12
- export function TagInput({ value, onChange, className, placeholder = 'Type and press Enter...' }: TagInputProps) {
13
+ export function TagInput({ value, onChange, className, placeholder = '' }: TagInputProps) {
13
14
  const [input, setInput] = useState('');
14
15
 
15
16
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -49,7 +50,7 @@ export function TagInput({ value, onChange, className, placeholder = 'Type and p
49
50
  onChange={(e) => setInput(e.target.value)}
50
51
  onKeyDown={handleKeyDown}
51
52
  className="flex-1 outline-none min-w-[100px] bg-transparent text-sm"
52
- placeholder={placeholder}
53
+ placeholder={placeholder || t('enterTag')}
53
54
  />
54
55
  </div>
55
56
  );
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useEffect, useState } from 'react';
2
2
  import { useConfig, useConfigSchema, useUpdateChannel, useExecuteConfigAction } from '@/hooks/useConfig';
3
3
  import { useUiStore } from '@/stores/ui.store';
4
4
  import {
@@ -65,102 +65,103 @@ const getFieldIcon = (fieldName: string) => {
65
65
  return <Settings className="h-3.5 w-3.5 text-gray-500" />;
66
66
  };
67
67
 
68
- // Channel field definitions
69
- const CHANNEL_FIELDS: Record<string, ChannelField[]> = {
70
- telegram: [
71
- { name: 'enabled', type: 'boolean', label: t('enabled') },
72
- { name: 'token', type: 'password', label: t('botToken') },
73
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
74
- { name: 'proxy', type: 'text', label: t('proxy') },
75
- { name: 'accountId', type: 'text', label: 'Account ID' },
76
- { name: 'dmPolicy', type: 'select', label: 'DM Policy', options: DM_POLICY_OPTIONS },
77
- { name: 'groupPolicy', type: 'select', label: 'Group Policy', options: GROUP_POLICY_OPTIONS },
78
- { name: 'groupAllowFrom', type: 'tags', label: 'Group Allow From' },
79
- { name: 'requireMention', type: 'boolean', label: 'Require Mention' },
80
- { name: 'mentionPatterns', type: 'tags', label: 'Mention Patterns' },
81
- { name: 'groups', type: 'json', label: 'Group Rules (JSON)' }
82
- ],
83
- discord: [
84
- { name: 'enabled', type: 'boolean', label: t('enabled') },
85
- { name: 'token', type: 'password', label: t('botToken') },
86
- { name: 'allowBots', type: 'boolean', label: 'Allow Bot Messages' },
87
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
88
- { name: 'gatewayUrl', type: 'text', label: t('gatewayUrl') },
89
- { name: 'intents', type: 'number', label: t('intents') },
90
- { name: 'proxy', type: 'text', label: t('proxy') },
91
- { name: 'mediaMaxMb', type: 'number', label: 'Attachment Max Size (MB)' },
92
- { name: 'streaming', type: 'select', label: 'Streaming Mode', options: STREAMING_MODE_OPTIONS },
93
- { name: 'draftChunk', type: 'json', label: 'Draft Chunking (JSON)' },
94
- { name: 'textChunkLimit', type: 'number', label: 'Text Chunk Limit' },
95
- { name: 'accountId', type: 'text', label: 'Account ID' },
96
- { name: 'dmPolicy', type: 'select', label: 'DM Policy', options: DM_POLICY_OPTIONS },
97
- { name: 'groupPolicy', type: 'select', label: 'Group Policy', options: GROUP_POLICY_OPTIONS },
98
- { name: 'groupAllowFrom', type: 'tags', label: 'Group Allow From' },
99
- { name: 'requireMention', type: 'boolean', label: 'Require Mention' },
100
- { name: 'mentionPatterns', type: 'tags', label: 'Mention Patterns' },
101
- { name: 'groups', type: 'json', label: 'Group Rules (JSON)' }
102
- ],
103
- whatsapp: [
104
- { name: 'enabled', type: 'boolean', label: t('enabled') },
105
- { name: 'bridgeUrl', type: 'text', label: t('bridgeUrl') },
106
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
107
- ],
108
- feishu: [
109
- { name: 'enabled', type: 'boolean', label: t('enabled') },
110
- { name: 'appId', type: 'text', label: t('appId') },
111
- { name: 'appSecret', type: 'password', label: t('appSecret') },
112
- { name: 'encryptKey', type: 'password', label: t('encryptKey') },
113
- { name: 'verificationToken', type: 'password', label: t('verificationToken') },
114
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
115
- ],
116
- dingtalk: [
117
- { name: 'enabled', type: 'boolean', label: t('enabled') },
118
- { name: 'clientId', type: 'text', label: t('clientId') },
119
- { name: 'clientSecret', type: 'password', label: t('clientSecret') },
120
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
121
- ],
122
- wecom: [
123
- { name: 'enabled', type: 'boolean', label: t('enabled') },
124
- { name: 'corpId', type: 'text', label: t('corpId') },
125
- { name: 'agentId', type: 'text', label: t('agentId') },
126
- { name: 'secret', type: 'password', label: t('secret') },
127
- { name: 'token', type: 'password', label: t('token') },
128
- { name: 'callbackPort', type: 'number', label: t('callbackPort') },
129
- { name: 'callbackPath', type: 'text', label: t('callbackPath') },
130
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
131
- ],
132
- slack: [
133
- { name: 'enabled', type: 'boolean', label: t('enabled') },
134
- { name: 'mode', type: 'text', label: t('mode') },
135
- { name: 'webhookPath', type: 'text', label: t('webhookPath') },
136
- { name: 'allowBots', type: 'boolean', label: 'Allow Bot Messages' },
137
- { name: 'botToken', type: 'password', label: t('botToken') },
138
- { name: 'appToken', type: 'password', label: t('appToken') }
139
- ],
140
- email: [
141
- { name: 'enabled', type: 'boolean', label: t('enabled') },
142
- { name: 'consentGranted', type: 'boolean', label: t('consentGranted') },
143
- { name: 'imapHost', type: 'text', label: t('imapHost') },
144
- { name: 'imapPort', type: 'number', label: t('imapPort') },
145
- { name: 'imapUsername', type: 'text', label: t('imapUsername') },
146
- { name: 'imapPassword', type: 'password', label: t('imapPassword') },
147
- { name: 'fromAddress', type: 'email', label: t('fromAddress') }
148
- ],
149
- mochat: [
150
- { name: 'enabled', type: 'boolean', label: t('enabled') },
151
- { name: 'baseUrl', type: 'text', label: t('baseUrl') },
152
- { name: 'clawToken', type: 'password', label: t('clawToken') },
153
- { name: 'agentUserId', type: 'text', label: t('agentUserId') },
154
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
155
- ],
156
- qq: [
157
- { name: 'enabled', type: 'boolean', label: t('enabled') },
158
- { name: 'appId', type: 'text', label: t('appId') },
159
- { name: 'secret', type: 'password', label: t('appSecret') },
160
- { name: 'markdownSupport', type: 'boolean', label: t('markdownSupport') },
161
- { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
162
- ]
163
- };
68
+ function buildChannelFields(): Record<string, ChannelField[]> {
69
+ return {
70
+ telegram: [
71
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
72
+ { name: 'token', type: 'password', label: t('botToken') },
73
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
74
+ { name: 'proxy', type: 'text', label: t('proxy') },
75
+ { name: 'accountId', type: 'text', label: t('accountId') },
76
+ { name: 'dmPolicy', type: 'select', label: t('dmPolicy'), options: DM_POLICY_OPTIONS },
77
+ { name: 'groupPolicy', type: 'select', label: t('groupPolicy'), options: GROUP_POLICY_OPTIONS },
78
+ { name: 'groupAllowFrom', type: 'tags', label: t('groupAllowFrom') },
79
+ { name: 'requireMention', type: 'boolean', label: t('requireMention') },
80
+ { name: 'mentionPatterns', type: 'tags', label: t('mentionPatterns') },
81
+ { name: 'groups', type: 'json', label: t('groupRulesJson') }
82
+ ],
83
+ discord: [
84
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
85
+ { name: 'token', type: 'password', label: t('botToken') },
86
+ { name: 'allowBots', type: 'boolean', label: t('allowBotMessages') },
87
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') },
88
+ { name: 'gatewayUrl', type: 'text', label: t('gatewayUrl') },
89
+ { name: 'intents', type: 'number', label: t('intents') },
90
+ { name: 'proxy', type: 'text', label: t('proxy') },
91
+ { name: 'mediaMaxMb', type: 'number', label: t('attachmentMaxSizeMb') },
92
+ { name: 'streaming', type: 'select', label: t('streamingMode'), options: STREAMING_MODE_OPTIONS },
93
+ { name: 'draftChunk', type: 'json', label: t('draftChunkingJson') },
94
+ { name: 'textChunkLimit', type: 'number', label: t('textChunkLimit') },
95
+ { name: 'accountId', type: 'text', label: t('accountId') },
96
+ { name: 'dmPolicy', type: 'select', label: t('dmPolicy'), options: DM_POLICY_OPTIONS },
97
+ { name: 'groupPolicy', type: 'select', label: t('groupPolicy'), options: GROUP_POLICY_OPTIONS },
98
+ { name: 'groupAllowFrom', type: 'tags', label: t('groupAllowFrom') },
99
+ { name: 'requireMention', type: 'boolean', label: t('requireMention') },
100
+ { name: 'mentionPatterns', type: 'tags', label: t('mentionPatterns') },
101
+ { name: 'groups', type: 'json', label: t('groupRulesJson') }
102
+ ],
103
+ whatsapp: [
104
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
105
+ { name: 'bridgeUrl', type: 'text', label: t('bridgeUrl') },
106
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
107
+ ],
108
+ feishu: [
109
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
110
+ { name: 'appId', type: 'text', label: t('appId') },
111
+ { name: 'appSecret', type: 'password', label: t('appSecret') },
112
+ { name: 'encryptKey', type: 'password', label: t('encryptKey') },
113
+ { name: 'verificationToken', type: 'password', label: t('verificationToken') },
114
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
115
+ ],
116
+ dingtalk: [
117
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
118
+ { name: 'clientId', type: 'text', label: t('clientId') },
119
+ { name: 'clientSecret', type: 'password', label: t('clientSecret') },
120
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
121
+ ],
122
+ wecom: [
123
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
124
+ { name: 'corpId', type: 'text', label: t('corpId') },
125
+ { name: 'agentId', type: 'text', label: t('agentId') },
126
+ { name: 'secret', type: 'password', label: t('secret') },
127
+ { name: 'token', type: 'password', label: t('token') },
128
+ { name: 'callbackPort', type: 'number', label: t('callbackPort') },
129
+ { name: 'callbackPath', type: 'text', label: t('callbackPath') },
130
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
131
+ ],
132
+ slack: [
133
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
134
+ { name: 'mode', type: 'text', label: t('mode') },
135
+ { name: 'webhookPath', type: 'text', label: t('webhookPath') },
136
+ { name: 'allowBots', type: 'boolean', label: t('allowBotMessages') },
137
+ { name: 'botToken', type: 'password', label: t('botToken') },
138
+ { name: 'appToken', type: 'password', label: t('appToken') }
139
+ ],
140
+ email: [
141
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
142
+ { name: 'consentGranted', type: 'boolean', label: t('consentGranted') },
143
+ { name: 'imapHost', type: 'text', label: t('imapHost') },
144
+ { name: 'imapPort', type: 'number', label: t('imapPort') },
145
+ { name: 'imapUsername', type: 'text', label: t('imapUsername') },
146
+ { name: 'imapPassword', type: 'password', label: t('imapPassword') },
147
+ { name: 'fromAddress', type: 'email', label: t('fromAddress') }
148
+ ],
149
+ mochat: [
150
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
151
+ { name: 'baseUrl', type: 'text', label: t('baseUrl') },
152
+ { name: 'clawToken', type: 'password', label: t('clawToken') },
153
+ { name: 'agentUserId', type: 'text', label: t('agentUserId') },
154
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
155
+ ],
156
+ qq: [
157
+ { name: 'enabled', type: 'boolean', label: t('enabled') },
158
+ { name: 'appId', type: 'text', label: t('appId') },
159
+ { name: 'secret', type: 'password', label: t('appSecret') },
160
+ { name: 'markdownSupport', type: 'boolean', label: t('markdownSupport') },
161
+ { name: 'allowFrom', type: 'tags', label: t('allowFrom') }
162
+ ]
163
+ };
164
+ }
164
165
 
165
166
  const channelIcons: Record<string, typeof MessageCircle> = {
166
167
  telegram: MessageCircle,
@@ -219,7 +220,7 @@ export function ChannelForm() {
219
220
 
220
221
  const channelName = channelModal.channel;
221
222
  const channelConfig = channelName ? config?.channels[channelName] : null;
222
- const fields = useMemo(() => (channelName ? CHANNEL_FIELDS[channelName] : []), [channelName]);
223
+ const fields = channelName ? buildChannelFields()[channelName] ?? [] : [];
223
224
  const uiHints = schema?.uiHints;
224
225
  const scope = channelName ? `channels.${channelName}` : null;
225
226
  const actions = schema?.actions?.filter((action) => action.scope === scope) ?? [];
@@ -231,7 +232,8 @@ export function ChannelForm() {
231
232
  if (channelConfig) {
232
233
  setFormData({ ...channelConfig });
233
234
  const nextDrafts: Record<string, string> = {};
234
- fields
235
+ const currentFields = channelName ? buildChannelFields()[channelName] ?? [] : [];
236
+ currentFields
235
237
  .filter((field) => field.type === 'json')
236
238
  .forEach((field) => {
237
239
  const value = channelConfig[field.name];
@@ -242,7 +244,7 @@ export function ChannelForm() {
242
244
  setFormData({});
243
245
  setJsonDrafts({});
244
246
  }
245
- }, [channelConfig, channelName, fields]);
247
+ }, [channelConfig, channelName]);
246
248
 
247
249
  const updateField = (name: string, value: unknown) => {
248
250
  setFormData((prev) => ({ ...prev, [name]: value }));
@@ -262,7 +264,7 @@ export function ChannelForm() {
262
264
  try {
263
265
  payload[field.name] = raw.trim() ? JSON.parse(raw) : {};
264
266
  } catch {
265
- toast.error(`Invalid JSON for ${field.name}`);
267
+ toast.error(`${t('invalidJson')}: ${field.name}`);
266
268
  return;
267
269
  }
268
270
  }
@@ -342,7 +344,7 @@ export function ChannelForm() {
342
344
  </div>
343
345
  <div>
344
346
  <DialogTitle className="capitalize">{channelLabel}</DialogTitle>
345
- <DialogDescription>Configure message channel parameters</DialogDescription>
347
+ <DialogDescription>{t('configureMessageChannelParameters')}</DialogDescription>
346
348
  </div>
347
349
  </div>
348
350
  </DialogHeader>
@@ -397,7 +399,7 @@ export function ChannelForm() {
397
399
  type="password"
398
400
  value={(formData[field.name] as string) || ''}
399
401
  onChange={(e) => updateField(field.name, e.target.value)}
400
- placeholder={placeholder ?? 'Leave blank to keep unchanged'}
402
+ placeholder={placeholder ?? t('leaveBlankToKeepUnchanged')}
401
403
  className="rounded-xl"
402
404
  />
403
405
  )}
@@ -468,7 +470,7 @@ export function ChannelForm() {
468
470
  type="submit"
469
471
  disabled={updateChannel.isPending || Boolean(runningActionId)}
470
472
  >
471
- {updateChannel.isPending ? 'Saving...' : t('save')}
473
+ {updateChannel.isPending ? t('saving') : t('save')}
472
474
  </Button>
473
475
  {actions
474
476
  .filter((action) => action.trigger === 'manual')
@@ -1,5 +1,5 @@
1
1
  import { useConfig, useConfigMeta, useConfigSchema } from '@/hooks/useConfig';
2
- import { MessageCircle, Mail, MessageSquare, Slack, ExternalLink, Bell, ArrowRight } from 'lucide-react';
2
+ import { MessageCircle, Mail, MessageSquare, Slack, ExternalLink, Bell } from 'lucide-react';
3
3
  import { useState } from 'react';
4
4
  import { ChannelForm } from './ChannelForm';
5
5
  import { useUiStore } from '@/stores/ui.store';
@@ -11,6 +11,8 @@ import { ConfigCard, ConfigCardHeader, ConfigCardBody, ConfigCardFooter } from '
11
11
  import { StatusDot } from '@/components/ui/status-dot';
12
12
  import { ActionLink } from '@/components/ui/action-link';
13
13
  import { cn } from '@/lib/utils';
14
+ import { t } from '@/lib/i18n';
15
+ import { PageLayout, PageHeader } from '@/components/layout/page-layout';
14
16
 
15
17
  const channelIcons: Record<string, typeof MessageCircle> = {
16
18
  telegram: MessageCircle,
@@ -20,13 +22,13 @@ const channelIcons: Record<string, typeof MessageCircle> = {
20
22
  default: MessageSquare
21
23
  };
22
24
 
23
- const channelDescriptions: Record<string, string> = {
24
- telegram: 'Connect with Telegram bots for instant messaging',
25
- slack: 'Integrate with Slack workspaces for team collaboration',
26
- email: 'Send and receive messages via email protocols',
27
- webhook: 'Receive HTTP webhooks for custom integrations',
28
- discord: 'Connect Discord bots to your community servers',
29
- feishu: 'Enterprise messaging and collaboration platform'
25
+ const channelDescriptionKeys: Record<string, string> = {
26
+ telegram: 'channelDescTelegram',
27
+ slack: 'channelDescSlack',
28
+ email: 'channelDescEmail',
29
+ webhook: 'channelDescWebhook',
30
+ discord: 'channelDescDiscord',
31
+ feishu: 'channelDescFeishu'
30
32
  };
31
33
 
32
34
  export function ChannelsList() {
@@ -38,12 +40,12 @@ export function ChannelsList() {
38
40
  const uiHints = schema?.uiHints;
39
41
 
40
42
  if (!config || !meta) {
41
- return <div className="p-8 text-gray-400">Loading channels...</div>;
43
+ return <div className="p-8 text-gray-400">{t('channelsLoading')}</div>;
42
44
  }
43
45
 
44
46
  const tabs = [
45
- { id: 'active', label: 'Enabled', count: meta.channels.filter(c => config.channels[c.name]?.enabled).length },
46
- { id: 'all', label: 'All Channels', count: meta.channels.length }
47
+ { id: 'active', label: t('channelsTabEnabled'), count: meta.channels.filter(c => config.channels[c.name]?.enabled).length },
48
+ { id: 'all', label: t('channelsTabAll'), count: meta.channels.length }
47
49
  ];
48
50
 
49
51
  const filteredChannels = meta.channels.filter(channel => {
@@ -52,10 +54,8 @@ export function ChannelsList() {
52
54
  });
53
55
 
54
56
  return (
55
- <div className="animate-fade-in pb-20">
56
- <div className="flex items-center justify-between mb-6">
57
- <h2 className="text-xl font-semibold text-gray-900">Message Channels</h2>
58
- </div>
57
+ <PageLayout>
58
+ <PageHeader title={t('channelsPageTitle')} />
59
59
 
60
60
  <Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
61
61
 
@@ -68,8 +68,7 @@ export function ChannelsList() {
68
68
  const channelHint = hintForPath(`channels.${channel.name}`, uiHints);
69
69
  const description =
70
70
  channelHint?.help ||
71
- channelDescriptions[channel.name] ||
72
- 'Configure this communication channel';
71
+ t(channelDescriptionKeys[channel.name] || 'channelDescriptionDefault');
73
72
 
74
73
  return (
75
74
  <ConfigCard key={channel.name} onClick={() => openChannelModal(channel.name)}>
@@ -88,7 +87,7 @@ export function ChannelsList() {
88
87
  />
89
88
  <StatusDot
90
89
  status={enabled ? 'active' : 'inactive'}
91
- label={enabled ? 'Active' : 'Inactive'}
90
+ label={enabled ? t('statusActive') : t('statusInactive')}
92
91
  />
93
92
  </ConfigCardHeader>
94
93
 
@@ -98,7 +97,7 @@ export function ChannelsList() {
98
97
  />
99
98
 
100
99
  <ConfigCardFooter>
101
- <ActionLink label={enabled ? 'Configure' : 'Enable'} />
100
+ <ActionLink label={enabled ? t('actionConfigure') : t('actionEnable')} />
102
101
  {channel.tutorialUrl && (
103
102
  <a
104
103
  href={channel.tutorialUrl}
@@ -106,7 +105,7 @@ export function ChannelsList() {
106
105
  rel="noreferrer"
107
106
  onClick={(e) => e.stopPropagation()}
108
107
  className="flex items-center justify-center h-6 w-6 rounded-md text-gray-300 hover:text-gray-500 hover:bg-gray-100/60 transition-colors"
109
- title="View Guide"
108
+ title={t('channelsGuideTitle')}
110
109
  >
111
110
  <ExternalLink className="h-3.5 w-3.5" />
112
111
  </a>
@@ -124,15 +123,15 @@ export function ChannelsList() {
124
123
  <MessageSquare className="h-6 w-6 text-gray-300" />
125
124
  </div>
126
125
  <h3 className="text-[14px] font-semibold text-gray-900 mb-1.5">
127
- No channels enabled
126
+ {t('channelsEmptyTitle')}
128
127
  </h3>
129
128
  <p className="text-[13px] text-gray-400 max-w-sm">
130
- Enable a messaging channel to start receiving messages.
129
+ {t('channelsEmptyDescription')}
131
130
  </p>
132
131
  </div>
133
132
  )}
134
133
 
135
134
  <ChannelForm />
136
- </div>
135
+ </PageLayout>
137
136
  );
138
137
  }
@@ -7,31 +7,21 @@ import { Input } from '@/components/ui/input';
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
8
  import { Card, CardContent } from '@/components/ui/card';
9
9
  import { cn } from '@/lib/utils';
10
- import { t } from '@/lib/i18n';
10
+ import { formatDateTime, t } from '@/lib/i18n';
11
+ import { PageLayout, PageHeader, PageBody } from '@/components/layout/page-layout';
11
12
  import { AlarmClock, RefreshCw, Trash2, Play, Power } from 'lucide-react';
12
13
 
13
14
  type StatusFilter = 'all' | 'enabled' | 'disabled';
14
15
 
15
16
  function formatDate(value?: string | null): string {
16
- if (!value) {
17
- return '-';
18
- }
19
- const date = new Date(value);
20
- if (Number.isNaN(date.getTime())) {
21
- return value;
22
- }
23
- return date.toLocaleString();
17
+ return formatDateTime(value ?? undefined);
24
18
  }
25
19
 
26
20
  function formatDateFromMs(value?: number | null): string {
27
21
  if (typeof value !== 'number' || !Number.isFinite(value)) {
28
22
  return '-';
29
23
  }
30
- const date = new Date(value);
31
- if (Number.isNaN(date.getTime())) {
32
- return '-';
33
- }
34
- return date.toLocaleString();
24
+ return formatDateTime(new Date(value));
35
25
  }
36
26
 
37
27
  function formatEveryDuration(ms?: number | null): string {
@@ -141,21 +131,21 @@ export function CronConfig() {
141
131
  };
142
132
 
143
133
  return (
144
- <div className="h-[calc(100vh-80px)] w-full max-w-[1200px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
145
- <div className="flex items-center justify-between mb-6 shrink-0">
146
- <div>
147
- <h2 className="text-xl font-semibold text-gray-900 tracking-tight">{t('cronPageTitle')}</h2>
148
- <p className="text-sm text-gray-500 mt-1">{t('cronPageDescription')}</p>
149
- </div>
150
- <Button
151
- variant="ghost"
152
- size="icon"
153
- className="h-9 w-9 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100"
154
- onClick={() => cronQuery.refetch()}
155
- >
156
- <RefreshCw className={cn('h-4 w-4', cronQuery.isFetching && 'animate-spin')} />
157
- </Button>
158
- </div>
134
+ <PageLayout fullHeight>
135
+ <PageHeader
136
+ title={t('cronPageTitle')}
137
+ description={t('cronPageDescription')}
138
+ actions={
139
+ <Button
140
+ variant="ghost"
141
+ size="icon"
142
+ className="h-9 w-9 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100"
143
+ onClick={() => cronQuery.refetch()}
144
+ >
145
+ <RefreshCw className={cn('h-4 w-4', cronQuery.isFetching && 'animate-spin')} />
146
+ </Button>
147
+ }
148
+ />
159
149
 
160
150
  <div className="mb-6">
161
151
  <div className="flex flex-wrap gap-3 items-center">
@@ -285,6 +275,6 @@ export function CronConfig() {
285
275
  )}
286
276
  </div>
287
277
  <ConfirmDialog />
288
- </div>
278
+ </PageLayout>
289
279
  );
290
280
  }