@nastechai/agent 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,524 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useState } from "react";
2
+ import { Clock, Pause, Play, Trash2, X, Zap } from "lucide-react";
3
+ import { Badge } from "@nastechai/ui/ui/components/badge";
4
+ import { Button } from "@nastechai/ui/ui/components/button";
5
+ import { Select, SelectOption } from "@nastechai/ui/ui/components/select";
6
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
7
+ import { H2 } from "@nastechai/ui/ui/components/typography/h2";
8
+ import { api } from "@/lib/api";
9
+ import type { CronJob, ProfileInfo } from "@/lib/api";
10
+ import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
11
+ import { useToast } from "@nastechai/ui/hooks/use-toast";
12
+ import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
13
+ import { useModalBehavior } from "@/hooks/useModalBehavior";
14
+ import { Toast } from "@nastechai/ui/ui/components/toast";
15
+ import { Card, CardContent } from "@nastechai/ui/ui/components/card";
16
+ import { Input } from "@nastechai/ui/ui/components/input";
17
+ import { Label } from "@nastechai/ui/ui/components/label";
18
+ import { useI18n } from "@/i18n";
19
+ import { usePageHeader } from "@/contexts/usePageHeader";
20
+ import { PluginSlot } from "@/plugins";
21
+ import { cn, themedBody } from "@/lib/utils";
22
+
23
+ function formatTime(iso?: string | null): string {
24
+ if (!iso) return "—";
25
+ const d = new Date(iso);
26
+ return d.toLocaleString();
27
+ }
28
+
29
+ function asText(value: unknown): string {
30
+ return typeof value === "string" ? value : "";
31
+ }
32
+
33
+ function truncateText(value: string, maxLength: number): string {
34
+ return value.length > maxLength
35
+ ? value.slice(0, maxLength) + "..."
36
+ : value;
37
+ }
38
+
39
+ function getJobPrompt(job: CronJob): string {
40
+ return asText(job.prompt);
41
+ }
42
+
43
+ function getJobName(job: CronJob): string {
44
+ return asText(job.name).trim();
45
+ }
46
+
47
+ function getJobTitle(job: CronJob): string {
48
+ const name = getJobName(job);
49
+ if (name) return name;
50
+
51
+ const prompt = getJobPrompt(job);
52
+ if (prompt) return truncateText(prompt, 60);
53
+
54
+ const script = asText(job.script);
55
+ if (script) return truncateText(script, 60);
56
+
57
+ return job.id || "Cron job";
58
+ }
59
+
60
+ function getJobScheduleDisplay(job: CronJob): string {
61
+ return (
62
+ asText(job.schedule_display) ||
63
+ asText(job.schedule?.display) ||
64
+ asText(job.schedule?.expr) ||
65
+ "—"
66
+ );
67
+ }
68
+
69
+ function getJobState(job: CronJob): string {
70
+ return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
71
+ }
72
+
73
+ function getJobProfile(job: CronJob): string {
74
+ return asText(job.profile) || asText(job.profile_name) || "default";
75
+ }
76
+
77
+ function getJobKey(job: CronJob): string {
78
+ return `${getJobProfile(job)}:${job.id}`;
79
+ }
80
+
81
+ function splitJobKey(key: string): { profile: string; id: string } {
82
+ const idx = key.indexOf(":");
83
+ if (idx === -1) return { profile: "default", id: key };
84
+ return { profile: key.slice(0, idx) || "default", id: key.slice(idx + 1) };
85
+ }
86
+
87
+ function profileLabel(profile: string): string {
88
+ return profile === "default" ? "default" : profile;
89
+ }
90
+
91
+ const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
92
+ enabled: "success",
93
+ scheduled: "success",
94
+ paused: "warning",
95
+ error: "destructive",
96
+ completed: "destructive",
97
+ };
98
+
99
+ export default function CronPage() {
100
+ const [jobs, setJobs] = useState<CronJob[]>([]);
101
+ const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
102
+ const [selectedProfile, setSelectedProfile] = useState("all");
103
+ const [loading, setLoading] = useState(true);
104
+ const { toast, showToast } = useToast();
105
+ const { t } = useI18n();
106
+ const { setEnd } = usePageHeader();
107
+
108
+ // New job modal state
109
+ const [createModalOpen, setCreateModalOpen] = useState(false);
110
+ const [prompt, setPrompt] = useState("");
111
+ const [schedule, setSchedule] = useState("");
112
+ const [name, setName] = useState("");
113
+ const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
114
+ const createModalRef = useModalBehavior({
115
+ open: createModalOpen,
116
+ onClose: closeCreateModal,
117
+ });
118
+ const [deliver, setDeliver] = useState("local");
119
+ const [creating, setCreating] = useState(false);
120
+ const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
121
+
122
+ const loadJobs = useCallback(() => {
123
+ api
124
+ .getCronJobs(selectedProfile)
125
+ .then(setJobs)
126
+ .catch(() => showToast(t.common.loading, "error"))
127
+ .finally(() => setLoading(false));
128
+ }, [selectedProfile, showToast, t.common.loading]);
129
+
130
+ useEffect(() => {
131
+ api
132
+ .getProfiles()
133
+ .then((res) => setProfiles(res.profiles))
134
+ .catch(() => setProfiles([]));
135
+ }, []);
136
+
137
+ useEffect(() => {
138
+ loadJobs();
139
+ }, [loadJobs]);
140
+
141
+ const handleCreate = async () => {
142
+ if (!prompt.trim() || !schedule.trim()) {
143
+ showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
144
+ return;
145
+ }
146
+ setCreating(true);
147
+ try {
148
+ await api.createCronJob(
149
+ {
150
+ prompt: prompt.trim(),
151
+ schedule: schedule.trim(),
152
+ name: name.trim() || undefined,
153
+ deliver,
154
+ },
155
+ createProfile,
156
+ );
157
+ showToast(t.common.create + " ✓", "success");
158
+ setPrompt("");
159
+ setSchedule("");
160
+ setName("");
161
+ setDeliver("local");
162
+ setCreateModalOpen(false);
163
+ loadJobs();
164
+ } catch (e) {
165
+ showToast(`${t.config.failedToSave}: ${e}`, "error");
166
+ } finally {
167
+ setCreating(false);
168
+ }
169
+ };
170
+
171
+ const handlePauseResume = async (job: CronJob) => {
172
+ try {
173
+ const isPaused = getJobState(job) === "paused";
174
+ const profile = getJobProfile(job);
175
+ if (isPaused) {
176
+ await api.resumeCronJob(job.id, profile);
177
+ showToast(
178
+ `${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
179
+ "success",
180
+ );
181
+ } else {
182
+ await api.pauseCronJob(job.id, profile);
183
+ showToast(
184
+ `${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
185
+ "success",
186
+ );
187
+ }
188
+ loadJobs();
189
+ } catch (e) {
190
+ showToast(`${t.status.error}: ${e}`, "error");
191
+ }
192
+ };
193
+
194
+ const handleTrigger = async (job: CronJob) => {
195
+ try {
196
+ await api.triggerCronJob(job.id, getJobProfile(job));
197
+ showToast(
198
+ `${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
199
+ "success",
200
+ );
201
+ loadJobs();
202
+ } catch (e) {
203
+ showToast(`${t.status.error}: ${e}`, "error");
204
+ }
205
+ };
206
+
207
+ const jobDelete = useConfirmDelete({
208
+ onDelete: useCallback(
209
+ async (key: string) => {
210
+ const { profile, id } = splitJobKey(key);
211
+ const job = jobs.find((j) => getJobKey(j) === key);
212
+ try {
213
+ await api.deleteCronJob(id, profile);
214
+ showToast(
215
+ `${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
216
+ "success",
217
+ );
218
+ loadJobs();
219
+ } catch (e) {
220
+ showToast(`${t.status.error}: ${e}`, "error");
221
+ throw e;
222
+ }
223
+ },
224
+ [jobs, loadJobs, showToast, t.common.delete, t.status.error],
225
+ ),
226
+ });
227
+
228
+ // Put "Create" button in page header
229
+ useLayoutEffect(() => {
230
+ setEnd(
231
+ <Button
232
+ className="uppercase"
233
+ size="sm"
234
+ onClick={() => setCreateModalOpen(true)}
235
+ >
236
+ {t.common.create}
237
+ </Button>,
238
+ );
239
+ return () => {
240
+ setEnd(null);
241
+ };
242
+ }, [setEnd, t.common.create, loading]);
243
+
244
+ if (loading) {
245
+ return (
246
+ <div className="flex items-center justify-center py-24">
247
+ <Spinner className="text-2xl text-primary" />
248
+ </div>
249
+ );
250
+ }
251
+
252
+ const pendingJob = jobDelete.pendingId
253
+ ? jobs.find((j) => getJobKey(j) === jobDelete.pendingId)
254
+ : null;
255
+
256
+ return (
257
+ <div className="flex flex-col gap-6">
258
+ <PluginSlot name="cron:top" />
259
+ <Toast toast={toast} />
260
+
261
+ <DeleteConfirmDialog
262
+ open={jobDelete.isOpen}
263
+ onCancel={jobDelete.cancel}
264
+ onConfirm={jobDelete.confirm}
265
+ title={t.cron.confirmDeleteTitle}
266
+ description={
267
+ pendingJob
268
+ ? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${
269
+ t.cron.confirmDeleteMessage
270
+ }`
271
+ : t.cron.confirmDeleteMessage
272
+ }
273
+ loading={jobDelete.isDeleting}
274
+ />
275
+
276
+ {/* Create job modal */}
277
+ {createModalOpen && (
278
+ <div
279
+ ref={createModalRef}
280
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
281
+ onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
282
+ role="dialog"
283
+ aria-modal="true"
284
+ aria-labelledby="create-cron-title"
285
+ >
286
+ <div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
287
+ <Button
288
+ ghost
289
+ size="icon"
290
+ onClick={() => setCreateModalOpen(false)}
291
+ className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
292
+ aria-label="Close"
293
+ >
294
+ <X />
295
+ </Button>
296
+
297
+ <header className="p-5 pb-3 border-b border-border">
298
+ <h2
299
+ id="create-cron-title"
300
+ className="font-mondwest text-display text-base tracking-wider"
301
+ >
302
+ {t.cron.newJob}
303
+ </h2>
304
+ </header>
305
+
306
+ <div className="p-5 grid gap-4">
307
+ <div className="grid gap-2">
308
+ <Label htmlFor="cron-profile">Profile</Label>
309
+ <Select
310
+ id="cron-profile"
311
+ value={createProfile}
312
+ onValueChange={(v) => setSelectedProfile(v)}
313
+ >
314
+ {profiles.map((profile) => (
315
+ <SelectOption key={profile.name} value={profile.name}>
316
+ {profileLabel(profile.name)}
317
+ </SelectOption>
318
+ ))}
319
+ </Select>
320
+ </div>
321
+
322
+ <div className="grid gap-2">
323
+ <Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
324
+ <Input
325
+ id="cron-name"
326
+ autoFocus
327
+ placeholder={t.cron.namePlaceholder}
328
+ value={name}
329
+ onChange={(e) => setName(e.target.value)}
330
+ />
331
+ </div>
332
+
333
+ <div className="grid gap-2">
334
+ <Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
335
+ <textarea
336
+ id="cron-prompt"
337
+ className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
338
+ placeholder={t.cron.promptPlaceholder}
339
+ value={prompt}
340
+ onChange={(e) => setPrompt(e.target.value)}
341
+ />
342
+ </div>
343
+
344
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
345
+ <div className="grid gap-2">
346
+ <Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
347
+ <Input
348
+ id="cron-schedule"
349
+ placeholder={t.cron.schedulePlaceholder}
350
+ value={schedule}
351
+ onChange={(e) => setSchedule(e.target.value)}
352
+ />
353
+ </div>
354
+
355
+ <div className="grid gap-2">
356
+ <Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
357
+ <Select
358
+ id="cron-deliver"
359
+ value={deliver}
360
+ onValueChange={(v) => setDeliver(v)}
361
+ >
362
+ <SelectOption value="local">
363
+ {t.cron.delivery.local}
364
+ </SelectOption>
365
+ <SelectOption value="telegram">
366
+ {t.cron.delivery.telegram}
367
+ </SelectOption>
368
+ <SelectOption value="discord">
369
+ {t.cron.delivery.discord}
370
+ </SelectOption>
371
+ <SelectOption value="slack">
372
+ {t.cron.delivery.slack}
373
+ </SelectOption>
374
+ <SelectOption value="email">
375
+ {t.cron.delivery.email}
376
+ </SelectOption>
377
+ </Select>
378
+ </div>
379
+ </div>
380
+
381
+ <div className="flex justify-end">
382
+ <Button
383
+ className="uppercase"
384
+ size="sm"
385
+ onClick={handleCreate}
386
+ disabled={creating}
387
+ prefix={creating ? <Spinner /> : undefined}
388
+ >
389
+ {creating ? t.common.creating : t.common.create}
390
+ </Button>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ )}
396
+
397
+ <div className="flex flex-col gap-3">
398
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
399
+ <H2
400
+ variant="sm"
401
+ className="flex items-center gap-2 text-muted-foreground"
402
+ >
403
+ <Clock className="h-4 w-4" />
404
+ {t.cron.scheduledJobs} ({jobs.length})
405
+ </H2>
406
+
407
+ <div className="grid gap-1 min-w-[220px]">
408
+ <Label htmlFor="cron-profile-filter">Profile</Label>
409
+ <Select
410
+ id="cron-profile-filter"
411
+ value={selectedProfile}
412
+ onValueChange={(v) => setSelectedProfile(v)}
413
+ >
414
+ <SelectOption value="all">All profiles</SelectOption>
415
+ {profiles.map((profile) => (
416
+ <SelectOption key={profile.name} value={profile.name}>
417
+ {profileLabel(profile.name)}
418
+ </SelectOption>
419
+ ))}
420
+ </Select>
421
+ </div>
422
+ </div>
423
+
424
+ {jobs.length === 0 && (
425
+ <Card>
426
+ <CardContent className="py-8 text-center text-sm text-muted-foreground">
427
+ {t.cron.noJobs}
428
+ </CardContent>
429
+ </Card>
430
+ )}
431
+
432
+ {jobs.map((job) => {
433
+ const state = getJobState(job);
434
+ const promptText = getJobPrompt(job);
435
+ const title = getJobTitle(job);
436
+ const hasName = Boolean(getJobName(job));
437
+ const deliver = asText(job.deliver);
438
+ const profile = getJobProfile(job);
439
+ const jobKey = getJobKey(job);
440
+
441
+ return (
442
+ <Card key={jobKey}>
443
+ <CardContent className="flex items-start gap-4 py-4">
444
+ <div className="flex-1 min-w-0">
445
+ <div className="flex items-center gap-2 mb-1">
446
+ <span className="font-medium text-sm truncate">
447
+ {title}
448
+ </span>
449
+ <Badge tone={STATUS_TONE[state] ?? "secondary"}>
450
+ {state}
451
+ </Badge>
452
+ <Badge tone="outline">{profileLabel(profile)}</Badge>
453
+ {deliver && deliver !== "local" && (
454
+ <Badge tone="outline">{deliver}</Badge>
455
+ )}
456
+ </div>
457
+ {hasName && promptText && (
458
+ <p className="text-xs text-muted-foreground truncate mb-1">
459
+ {truncateText(promptText, 100)}
460
+ </p>
461
+ )}
462
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
463
+ <span className="font-mono">{getJobScheduleDisplay(job)}</span>
464
+ <span>
465
+ {t.cron.last}: {formatTime(job.last_run_at)}
466
+ </span>
467
+ <span>
468
+ {t.cron.next}: {formatTime(job.next_run_at)}
469
+ </span>
470
+ </div>
471
+ {job.last_error && (
472
+ <p className="text-xs text-destructive mt-1">
473
+ {job.last_error}
474
+ </p>
475
+ )}
476
+ </div>
477
+
478
+ <div className="flex items-center gap-1 shrink-0">
479
+ <Button
480
+ ghost
481
+ size="icon"
482
+ title={state === "paused" ? t.cron.resume : t.cron.pause}
483
+ aria-label={
484
+ state === "paused" ? t.cron.resume : t.cron.pause
485
+ }
486
+ onClick={() => handlePauseResume(job)}
487
+ className={
488
+ state === "paused" ? "text-success" : "text-warning"
489
+ }
490
+ >
491
+ {state === "paused" ? <Play /> : <Pause />}
492
+ </Button>
493
+
494
+ <Button
495
+ ghost
496
+ size="icon"
497
+ title={t.cron.triggerNow}
498
+ aria-label={t.cron.triggerNow}
499
+ onClick={() => handleTrigger(job)}
500
+ >
501
+ <Zap />
502
+ </Button>
503
+
504
+ <Button
505
+ ghost
506
+ destructive
507
+ size="icon"
508
+ title={t.common.delete}
509
+ aria-label={t.common.delete}
510
+ onClick={() => jobDelete.requestDelete(jobKey)}
511
+ >
512
+ <Trash2 />
513
+ </Button>
514
+ </div>
515
+ </CardContent>
516
+ </Card>
517
+ );
518
+ })}
519
+ </div>
520
+
521
+ <PluginSlot name="cron:bottom" />
522
+ </div>
523
+ );
524
+ }
@@ -0,0 +1,69 @@
1
+ import { useLayoutEffect } from "react";
2
+ import { ExternalLink } from "lucide-react";
3
+ import { useI18n } from "@/i18n";
4
+ import { usePageHeader } from "@/contexts/usePageHeader";
5
+ import { cn } from "@/lib/utils";
6
+ import { PluginSlot } from "@/plugins";
7
+
8
+ export const NASTECH_DOCS_URL = "https://docs.nastech-agent.workers.dev/docs/";
9
+
10
+ const DS_BUTTON_OUTLINED_LINK_CN = cn(
11
+ "group relative inline-grid grid-cols-[auto_1fr_auto] items-center",
12
+ "px-[.9em_.75em] py-[1.25em] gap-2",
13
+ "leading-0 font-bold tracking-[0.2em] uppercase",
14
+ "text-midground bg-transparent shadow-midground",
15
+ "shadow-[inset_-1px_-1px_0_0_#00000080,inset_1px_1px_0_0_#ffffff80]",
16
+ );
17
+
18
+ export default function DocsPage() {
19
+ const { t } = useI18n();
20
+ const { setEnd } = usePageHeader();
21
+
22
+ useLayoutEffect(() => {
23
+ setEnd(
24
+ <a
25
+ href={NASTECH_DOCS_URL}
26
+ target="_blank"
27
+ rel="noopener noreferrer"
28
+ className={DS_BUTTON_OUTLINED_LINK_CN}
29
+ >
30
+ <ExternalLink className="size-3.5" />
31
+ {t.app.openDocumentation}
32
+ </a>,
33
+ );
34
+ return () => {
35
+ setEnd(null);
36
+ };
37
+ }, [setEnd, t]);
38
+
39
+ return (
40
+ <div
41
+ className={cn(
42
+ "flex min-h-0 w-full min-w-0 flex-1 flex-col",
43
+ "pt-1 sm:pt-2",
44
+ )}
45
+ >
46
+ <PluginSlot name="docs:top" />
47
+ <iframe
48
+ title={t.app.nav.documentation}
49
+ src={NASTECH_DOCS_URL}
50
+ className={cn(
51
+ "min-h-0 w-full min-w-0 flex-1",
52
+ "rounded-sm border border-current/20",
53
+ // Docusaurus paints over a transparent <html> / <body> and
54
+ // relies on the browser's canvas color (light by default) to
55
+ // fill the viewport. Inheriting the dashboard's dark color
56
+ // scheme makes that canvas dark, so the docs body text — which
57
+ // is tuned for a light canvas — becomes near-invisible. Force a
58
+ // light color scheme + white background on the iframe element so
59
+ // the docs render cleanly regardless of the active dashboard
60
+ // theme or the user's prefers-color-scheme.
61
+ "[color-scheme:light] bg-white",
62
+ )}
63
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
64
+ referrerPolicy="no-referrer-when-downgrade"
65
+ />
66
+ <PluginSlot name="docs:bottom" />
67
+ </div>
68
+ );
69
+ }