kcg-code 0.1.0 → 0.2.1
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/README.md +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +880 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/ink-react.ts +26 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Bahasa UI sengaja non-teknis (tanpa "tunnel", "secret", "frpc"): istilah
|
|
10
10
|
* teknis & aksi jarang dipakai disimpan di bagian "Advanced" yang tertutup.
|
|
11
|
+
*
|
|
12
|
+
* Susunan halaman (satu hal per grup, ringkas di HP):
|
|
13
|
+
* 1. AdwSwitchRow "Remote access" + status satu frasa.
|
|
14
|
+
* 2. "Your link" (saat online): QR (desktop), baris link + salin/buka;
|
|
15
|
+
* di HP: "Share link" + QR dilipat di baris expander.
|
|
16
|
+
* 3. Akun Google tertaut.
|
|
17
|
+
* 4. "Connection method" (pilihan penyedia — jarang diganti, jadi di bawah).
|
|
18
|
+
* 5. "Advanced options" tertutup.
|
|
11
19
|
*/
|
|
12
20
|
import {
|
|
13
21
|
CheckIcon,
|
|
@@ -17,7 +25,6 @@ import {
|
|
|
17
25
|
ExternalLinkIcon,
|
|
18
26
|
GlobeIcon,
|
|
19
27
|
LockIcon,
|
|
20
|
-
PowerIcon,
|
|
21
28
|
QrCodeIcon,
|
|
22
29
|
RefreshCwIcon,
|
|
23
30
|
Settings2Icon,
|
|
@@ -29,7 +36,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
|
|
|
29
36
|
import { toast } from "sonner";
|
|
30
37
|
import { encode } from "uqr";
|
|
31
38
|
import { LhrRemoteAccess } from "@/components/settings/LhrRemoteAccess";
|
|
32
|
-
import { ActionRow, PrefsGroup, RowBadge } from "@/components/settings/prefs";
|
|
39
|
+
import { ActionRow, ExpanderRow, PrefsGroup, RowBadge } from "@/components/settings/prefs";
|
|
33
40
|
import {
|
|
34
41
|
AlertDialog,
|
|
35
42
|
AlertDialogAction,
|
|
@@ -43,6 +50,7 @@ import {
|
|
|
43
50
|
import { Button } from "@/components/ui/button";
|
|
44
51
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
45
52
|
import { Spinner } from "@/components/ui/spinner";
|
|
53
|
+
import { Switch } from "@/components/ui/switch";
|
|
46
54
|
import { useRouter } from "@/hooks/useRouter";
|
|
47
55
|
import { ApiError, apiErrorMessage, apiFetch } from "@/lib/api";
|
|
48
56
|
import { settingsPath } from "@/lib/routes";
|
|
@@ -158,14 +166,19 @@ export function RemoteAccessGroup({ protectedApp }: { protectedApp: boolean }) {
|
|
|
158
166
|
else void applySwitch(next);
|
|
159
167
|
};
|
|
160
168
|
|
|
169
|
+
// Pilihan penyedia dirender panel penyedia di posisinya (sebelum Advanced).
|
|
170
|
+
const methodPicker = (
|
|
171
|
+
<ProviderPicker
|
|
172
|
+
value={provider}
|
|
173
|
+
live={live.provider}
|
|
174
|
+
disabled={switching}
|
|
175
|
+
onChange={(p) => void choose(p)}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
|
|
161
179
|
return (
|
|
162
|
-
<div className="flex flex-col gap-
|
|
163
|
-
<
|
|
164
|
-
value={provider}
|
|
165
|
-
live={live.provider}
|
|
166
|
-
disabled={switching}
|
|
167
|
-
onChange={(p) => void choose(p)}
|
|
168
|
-
/>
|
|
180
|
+
<div className="flex flex-col gap-6">
|
|
181
|
+
<p className="px-1 text-[13px] leading-snug text-muted-foreground">{DESCRIPTION}</p>
|
|
169
182
|
<SwitchDialog
|
|
170
183
|
to={pendingSwitch}
|
|
171
184
|
viaTunnel={live.url !== null && sameOrigin(live.url)}
|
|
@@ -186,10 +199,10 @@ export function RemoteAccessGroup({ protectedApp }: { protectedApp: boolean }) {
|
|
|
186
199
|
LiveAddress={LiveAddress}
|
|
187
200
|
LogPanel={LogPanel}
|
|
188
201
|
onRequestStop={(run, via) => setLhrStop({ run, via })}
|
|
189
|
-
|
|
202
|
+
methodPicker={methodPicker}
|
|
190
203
|
/>
|
|
191
204
|
) : (
|
|
192
|
-
<KcgRemoteAccess protectedApp={protectedApp} />
|
|
205
|
+
<KcgRemoteAccess protectedApp={protectedApp} methodPicker={methodPicker} />
|
|
193
206
|
)}
|
|
194
207
|
<ConfirmDialog
|
|
195
208
|
kind={lhrStop ? "stop" : null}
|
|
@@ -223,13 +236,13 @@ const PROVIDERS: ProviderInfo[] = [
|
|
|
223
236
|
{
|
|
224
237
|
id: "kcg",
|
|
225
238
|
title: "KCG Code link",
|
|
226
|
-
subtitle: "
|
|
239
|
+
subtitle: "Recommended · fixed address",
|
|
227
240
|
recommended: true,
|
|
228
241
|
},
|
|
229
242
|
{
|
|
230
243
|
id: "lhr",
|
|
231
244
|
title: "localhost.run",
|
|
232
|
-
subtitle: "No account
|
|
245
|
+
subtitle: "No account · address changes",
|
|
233
246
|
},
|
|
234
247
|
];
|
|
235
248
|
|
|
@@ -255,9 +268,8 @@ function ProviderPicker({
|
|
|
255
268
|
<section aria-labelledby="remote-provider" className="flex flex-col gap-2.5">
|
|
256
269
|
<div className="flex flex-col gap-0.5 px-1">
|
|
257
270
|
<h2 id="remote-provider" className="text-[15px] font-semibold tracking-tight">
|
|
258
|
-
|
|
271
|
+
Connection method
|
|
259
272
|
</h2>
|
|
260
|
-
<p className="text-[13px] text-muted-foreground">{DESCRIPTION}</p>
|
|
261
273
|
</div>
|
|
262
274
|
{/* Boxed list ala AdwPreferencesGroup: satu baris radio per penyedia
|
|
263
275
|
(AdwActionRow + GtkCheckButton grup). Radio native di dalam label:
|
|
@@ -299,14 +311,7 @@ function ProviderPicker({
|
|
|
299
311
|
{selected && <span className="size-1.5 rounded-full bg-primary-foreground" />}
|
|
300
312
|
</span>
|
|
301
313
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
|
|
302
|
-
<span className="
|
|
303
|
-
<span className="text-[15px] leading-5">{p.title}</span>
|
|
304
|
-
{p.recommended && (
|
|
305
|
-
<span className="rounded-full bg-primary/12 px-2 py-px text-[11.5px] font-medium leading-4 text-primary">
|
|
306
|
-
Recommended
|
|
307
|
-
</span>
|
|
308
|
-
)}
|
|
309
|
-
</span>
|
|
314
|
+
<span className="text-[15px] leading-5">{p.title}</span>
|
|
310
315
|
<span className="text-[13px] leading-snug text-muted-foreground">
|
|
311
316
|
{p.subtitle}
|
|
312
317
|
</span>
|
|
@@ -378,7 +383,15 @@ function SwitchDialog({
|
|
|
378
383
|
);
|
|
379
384
|
}
|
|
380
385
|
|
|
381
|
-
function KcgRemoteAccess({
|
|
386
|
+
function KcgRemoteAccess({
|
|
387
|
+
protectedApp,
|
|
388
|
+
methodPicker,
|
|
389
|
+
}: {
|
|
390
|
+
protectedApp: boolean;
|
|
391
|
+
/** Grup "Connection method" — dirender sebelum Advanced. */
|
|
392
|
+
methodPicker: ReactNode;
|
|
393
|
+
}) {
|
|
394
|
+
const { navigate } = useRouter();
|
|
382
395
|
const [t, setT] = useState<TunnelStatus | null>(null);
|
|
383
396
|
const [busy, setBusy] = useState<string | null>(null);
|
|
384
397
|
const [confirm, setConfirm] = useState<ConfirmKind | null>(null);
|
|
@@ -426,40 +439,46 @@ function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
|
|
|
426
439
|
// ---- API tunnel belum diatur (KCG_TUNNEL_API_URL / build) ----
|
|
427
440
|
if (!t.configured) {
|
|
428
441
|
return (
|
|
429
|
-
|
|
430
|
-
<
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
442
|
+
<>
|
|
443
|
+
<PrefsGroup id="remote">
|
|
444
|
+
<ActionRow
|
|
445
|
+
prefix={<StatusIcon tone="muted" />}
|
|
446
|
+
title="Not available in this version"
|
|
447
|
+
subtitle={
|
|
448
|
+
<>
|
|
449
|
+
Remote access hasn't been set up for this copy of KCG Code. If you manage it, set{" "}
|
|
450
|
+
<code className="font-mono text-[12px]">KCG_TUNNEL_API_URL</code> and restart.
|
|
451
|
+
</>
|
|
452
|
+
}
|
|
453
|
+
/>
|
|
454
|
+
</PrefsGroup>
|
|
455
|
+
{methodPicker}
|
|
456
|
+
</>
|
|
441
457
|
);
|
|
442
458
|
}
|
|
443
459
|
|
|
444
460
|
// ---- belum tersambung ----
|
|
445
461
|
if (!t.account) {
|
|
446
462
|
return (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
+
<>
|
|
464
|
+
<PrefsGroup id="remote">
|
|
465
|
+
{t.pairing ? (
|
|
466
|
+
<PairingRow
|
|
467
|
+
pairing={t.pairing}
|
|
468
|
+
busy={busy !== null}
|
|
469
|
+
onCancel={() => void act("cancel", "/api/tunnel/connect/cancel")}
|
|
470
|
+
/>
|
|
471
|
+
) : (
|
|
472
|
+
<SetupSteps
|
|
473
|
+
protectedApp={protectedApp}
|
|
474
|
+
error={t.error}
|
|
475
|
+
busy={busy}
|
|
476
|
+
onStart={() => void act("connect", "/api/tunnel/connect")}
|
|
477
|
+
/>
|
|
478
|
+
)}
|
|
479
|
+
</PrefsGroup>
|
|
480
|
+
{methodPicker}
|
|
481
|
+
</>
|
|
463
482
|
);
|
|
464
483
|
}
|
|
465
484
|
|
|
@@ -476,27 +495,87 @@ function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
|
|
|
476
495
|
setConfirm(null);
|
|
477
496
|
};
|
|
478
497
|
|
|
479
|
-
|
|
480
|
-
const turnOff = () => setConfirm("stop");
|
|
498
|
+
const online = running && t.phase === "online" && protectedApp;
|
|
481
499
|
const account = t.account;
|
|
482
500
|
|
|
501
|
+
// Subjudul switch: satu frasa yang menjelaskan keadaan (pola penyedia
|
|
502
|
+
// localhost.run). Status lama yang memenuhi kartu (ikon besar, kotak QR
|
|
503
|
+
// kosong, tombol terpisah) dilebur ke sini.
|
|
504
|
+
let tone: Tone;
|
|
505
|
+
let subtitle: ReactNode;
|
|
506
|
+
if (!protectedApp) {
|
|
507
|
+
tone = "warning";
|
|
508
|
+
subtitle = "Needs a PIN or password first";
|
|
509
|
+
} else if (running && t.phase === "error") {
|
|
510
|
+
tone = "warning";
|
|
511
|
+
subtitle = (
|
|
512
|
+
<span className="text-destructive">
|
|
513
|
+
{t.error ? apiErrorMessage(t.error) : "Couldn't connect. Check your internet."}
|
|
514
|
+
</span>
|
|
515
|
+
);
|
|
516
|
+
} else if (online) {
|
|
517
|
+
tone = "success";
|
|
518
|
+
subtitle = "On · PIN still required";
|
|
519
|
+
} else if (running || busy === "start") {
|
|
520
|
+
tone = "busy";
|
|
521
|
+
subtitle = t.phase === "reconnecting" ? "Reconnecting…" : "Turning on…";
|
|
522
|
+
} else {
|
|
523
|
+
tone = "muted";
|
|
524
|
+
subtitle = "Off";
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Mematikan selalu dikonfirmasi: dari HP tidak bisa dinyalakan lagi.
|
|
528
|
+
const toggle = (next: boolean) => {
|
|
529
|
+
if (next) void act("start", "/api/tunnel/start");
|
|
530
|
+
else setConfirm("stop");
|
|
531
|
+
};
|
|
532
|
+
|
|
483
533
|
return (
|
|
484
534
|
<>
|
|
485
535
|
<PrefsGroup id="remote">
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
536
|
+
<SwitchRow
|
|
537
|
+
id="kcg-remote-switch"
|
|
538
|
+
icon={<StatusIcon tone={tone} />}
|
|
539
|
+
title="Remote access"
|
|
540
|
+
subtitle={subtitle}
|
|
541
|
+
checked={running || busy === "start"}
|
|
542
|
+
disabled={!protectedApp || busy !== null}
|
|
543
|
+
busy={busy === "start" || busy === "stop"}
|
|
544
|
+
onChange={toggle}
|
|
493
545
|
/>
|
|
546
|
+
{!protectedApp && (
|
|
547
|
+
<ActionRow
|
|
548
|
+
prefix={<RowGlyph icon={<LockIcon className="size-[18px]" aria-hidden />} />}
|
|
549
|
+
title="Set up app lock"
|
|
550
|
+
subtitle="Add a PIN or password in Security"
|
|
551
|
+
onActivate={() => navigate(settingsPath("security"))}
|
|
552
|
+
/>
|
|
553
|
+
)}
|
|
554
|
+
{protectedApp && running && t.phase === "error" && (
|
|
555
|
+
<ActionRow
|
|
556
|
+
prefix={<RowGlyph icon={<RefreshCwIcon className="size-[18px]" aria-hidden />} />}
|
|
557
|
+
title="Try again"
|
|
558
|
+
disabled={busy !== null}
|
|
559
|
+
onActivate={() => void act("start", "/api/tunnel/start")}
|
|
560
|
+
/>
|
|
561
|
+
)}
|
|
562
|
+
</PrefsGroup>
|
|
563
|
+
|
|
564
|
+
{online && (
|
|
565
|
+
<PrefsGroup title="Your link">
|
|
566
|
+
<LiveAddress url={account.url} />
|
|
567
|
+
</PrefsGroup>
|
|
568
|
+
)}
|
|
569
|
+
|
|
570
|
+
<PrefsGroup title="Account">
|
|
494
571
|
<ActionRow
|
|
495
|
-
title="
|
|
496
|
-
subtitle={<span className="break-all">{
|
|
572
|
+
title="Google account"
|
|
573
|
+
subtitle={<span className="break-all">{account.email}</span>}
|
|
497
574
|
/>
|
|
498
575
|
</PrefsGroup>
|
|
499
576
|
|
|
577
|
+
{methodPicker}
|
|
578
|
+
|
|
500
579
|
<AdvancedSection
|
|
501
580
|
busy={busy !== null}
|
|
502
581
|
onRotate={() => setConfirm("rotate")}
|
|
@@ -514,6 +593,52 @@ function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
|
|
|
514
593
|
);
|
|
515
594
|
}
|
|
516
595
|
|
|
596
|
+
/**
|
|
597
|
+
* AdwSwitchRow: ikon, judul + status di subjudul, switch di kanan. Seluruh
|
|
598
|
+
* baris adalah label switch (klik di mana saja mengubahnya).
|
|
599
|
+
*/
|
|
600
|
+
function SwitchRow({
|
|
601
|
+
id,
|
|
602
|
+
icon,
|
|
603
|
+
title,
|
|
604
|
+
subtitle,
|
|
605
|
+
checked,
|
|
606
|
+
disabled,
|
|
607
|
+
busy,
|
|
608
|
+
onChange,
|
|
609
|
+
}: {
|
|
610
|
+
id: string;
|
|
611
|
+
icon: ReactNode;
|
|
612
|
+
title: string;
|
|
613
|
+
subtitle: ReactNode;
|
|
614
|
+
checked: boolean;
|
|
615
|
+
disabled: boolean;
|
|
616
|
+
busy: boolean;
|
|
617
|
+
onChange: (checked: boolean) => void;
|
|
618
|
+
}) {
|
|
619
|
+
return (
|
|
620
|
+
<li>
|
|
621
|
+
<label
|
|
622
|
+
htmlFor={id}
|
|
623
|
+
className={cn(
|
|
624
|
+
"flex min-h-14 items-center gap-3 px-4 py-2.5",
|
|
625
|
+
!disabled && "cursor-pointer",
|
|
626
|
+
)}
|
|
627
|
+
>
|
|
628
|
+
{icon}
|
|
629
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
|
|
630
|
+
<span className="text-[15px] leading-5">{title}</span>
|
|
631
|
+
<span role="status" className="text-[13px] leading-snug text-muted-foreground">
|
|
632
|
+
{subtitle}
|
|
633
|
+
</span>
|
|
634
|
+
</span>
|
|
635
|
+
{busy && <Spinner className="size-4 text-muted-foreground" aria-hidden />}
|
|
636
|
+
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onChange} />
|
|
637
|
+
</label>
|
|
638
|
+
</li>
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
517
642
|
/** `true` bila halaman ini sedang dibuka lewat alamat tunnel. */
|
|
518
643
|
function sameOrigin(url: string): boolean {
|
|
519
644
|
try {
|
|
@@ -550,66 +675,6 @@ function StatusIcon({ tone, className }: { tone: Tone; className?: string }) {
|
|
|
550
675
|
);
|
|
551
676
|
}
|
|
552
677
|
|
|
553
|
-
/**
|
|
554
|
-
* Baris status di atas kartu, hanya saat remote access menyala (atau sedang
|
|
555
|
-
* menyala). Saat mati, status + tombol "Turn on" ada di `AddressPanel`.
|
|
556
|
-
*/
|
|
557
|
-
function StatusHeader({
|
|
558
|
-
t,
|
|
559
|
-
protectedApp,
|
|
560
|
-
busy,
|
|
561
|
-
onStop,
|
|
562
|
-
}: {
|
|
563
|
-
t: TunnelStatus;
|
|
564
|
-
protectedApp: boolean;
|
|
565
|
-
busy: string | null;
|
|
566
|
-
onStop: () => void;
|
|
567
|
-
}) {
|
|
568
|
-
let tone: Tone;
|
|
569
|
-
let title: string;
|
|
570
|
-
let body: string;
|
|
571
|
-
if (!protectedApp || t.phase === "error") {
|
|
572
|
-
tone = "warning";
|
|
573
|
-
title = "Couldn't connect";
|
|
574
|
-
body = !protectedApp
|
|
575
|
-
? "Set a PIN or password to keep using remote access."
|
|
576
|
-
: "Something went wrong. See below for details.";
|
|
577
|
-
} else if (t.phase === "online") {
|
|
578
|
-
tone = "success";
|
|
579
|
-
title = "Remote access is on";
|
|
580
|
-
body = "Anyone opening your link still needs your PIN or password.";
|
|
581
|
-
} else {
|
|
582
|
-
tone = "busy";
|
|
583
|
-
title = t.phase === "reconnecting" ? "Reconnecting…" : "Turning on…";
|
|
584
|
-
body = "This usually takes a few seconds.";
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
return (
|
|
588
|
-
<ActionRow
|
|
589
|
-
prefix={<StatusIcon tone={tone} />}
|
|
590
|
-
title={<span className="font-medium">{title}</span>}
|
|
591
|
-
subtitle={<span role="status">{body}</span>}
|
|
592
|
-
suffix={
|
|
593
|
-
<Button
|
|
594
|
-
type="button"
|
|
595
|
-
variant="outline"
|
|
596
|
-
size="sm"
|
|
597
|
-
disabled={busy !== null}
|
|
598
|
-
onClick={onStop}
|
|
599
|
-
className="hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/15"
|
|
600
|
-
>
|
|
601
|
-
{busy === "stop" ? (
|
|
602
|
-
<Spinner data-icon="inline-start" />
|
|
603
|
-
) : (
|
|
604
|
-
<PowerIcon data-icon="inline-start" />
|
|
605
|
-
)}
|
|
606
|
-
Turn off
|
|
607
|
-
</Button>
|
|
608
|
-
}
|
|
609
|
-
/>
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
678
|
// --------------------------------------------------------- not linked ----
|
|
614
679
|
|
|
615
680
|
function SetupSteps({
|
|
@@ -820,91 +885,15 @@ function QrCode({ value, label, className }: { value: string; label: string; cla
|
|
|
820
885
|
* placeholder seukuran QR berisi ikon, keterangan, dan aksi utama di tengah
|
|
821
886
|
* (Turn on / Try again / Set up lock) agar tata letak tidak "melompat".
|
|
822
887
|
*/
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
t: TunnelStatus & { account: NonNullable<TunnelStatus["account"]> };
|
|
831
|
-
running: boolean;
|
|
832
|
-
protectedApp: boolean;
|
|
833
|
-
busy: string | null;
|
|
834
|
-
onStart: () => void;
|
|
835
|
-
}) {
|
|
836
|
-
const { navigate } = useRouter();
|
|
837
|
-
|
|
838
|
-
if (t.phase === "online" && protectedApp) return <LiveAddress url={t.account.url} />;
|
|
839
|
-
|
|
840
|
-
let icon: ReactNode;
|
|
841
|
-
let title: string;
|
|
842
|
-
let body: string;
|
|
843
|
-
let action: ReactNode = null;
|
|
844
|
-
if (!protectedApp) {
|
|
845
|
-
icon = <LockIcon className="size-9" aria-hidden />;
|
|
846
|
-
title = "Set a PIN or password first";
|
|
847
|
-
body = "For your safety, remote access only works when KCG Code is locked.";
|
|
848
|
-
action = (
|
|
849
|
-
<Button type="button" onClick={() => navigate(settingsPath("security"))}>
|
|
850
|
-
<LockIcon data-icon="inline-start" />
|
|
851
|
-
Set up lock
|
|
852
|
-
</Button>
|
|
853
|
-
);
|
|
854
|
-
} else if (running && t.phase === "error") {
|
|
855
|
-
icon = <CircleAlertIcon className="size-9 text-amber-600 dark:text-amber-400" aria-hidden />;
|
|
856
|
-
title = "Your link isn't working right now";
|
|
857
|
-
body = t.error ? apiErrorMessage(t.error) : "Check your internet connection and try again.";
|
|
858
|
-
action = (
|
|
859
|
-
<Button type="button" disabled={busy !== null} onClick={onStart}>
|
|
860
|
-
{busy === "start" ? (
|
|
861
|
-
<Spinner data-icon="inline-start" />
|
|
862
|
-
) : (
|
|
863
|
-
<RefreshCwIcon data-icon="inline-start" />
|
|
864
|
-
)}
|
|
865
|
-
Try again
|
|
866
|
-
</Button>
|
|
867
|
-
);
|
|
868
|
-
} else if (running) {
|
|
869
|
-
icon = <Spinner className="size-9" />;
|
|
870
|
-
title = "Getting your link ready…";
|
|
871
|
-
body = "Your QR code and link will appear here in a moment.";
|
|
872
|
-
} else {
|
|
873
|
-
icon = <QrCodeIcon className="size-9" aria-hidden />;
|
|
874
|
-
title = "Remote access is off";
|
|
875
|
-
body = "Turn it on to get a QR code and link for opening KCG Code on your phone.";
|
|
876
|
-
action = (
|
|
877
|
-
<Button type="button" disabled={busy !== null} onClick={onStart}>
|
|
878
|
-
{busy === "start" ? (
|
|
879
|
-
<Spinner data-icon="inline-start" />
|
|
880
|
-
) : (
|
|
881
|
-
<PowerIcon data-icon="inline-start" />
|
|
882
|
-
)}
|
|
883
|
-
Turn on
|
|
884
|
-
</Button>
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
return (
|
|
889
|
-
<li className="flex flex-col items-center gap-4 px-4 py-6 text-center">
|
|
890
|
-
<div
|
|
891
|
-
className="flex size-40 items-center justify-center rounded-xl border-2 border-dashed bg-muted/30 text-muted-foreground"
|
|
892
|
-
aria-hidden
|
|
893
|
-
>
|
|
894
|
-
{icon}
|
|
895
|
-
</div>
|
|
896
|
-
<div className="flex max-w-sm flex-col gap-1" role="status">
|
|
897
|
-
<p className="text-[15px] font-medium">{title}</p>
|
|
898
|
-
<p className="text-[13px] leading-snug text-muted-foreground">{body}</p>
|
|
899
|
-
</div>
|
|
900
|
-
{action}
|
|
901
|
-
</li>
|
|
902
|
-
);
|
|
903
|
-
}
|
|
904
|
-
|
|
888
|
+
/**
|
|
889
|
+
* Baris "Your link" (dipakai kedua penyedia, di dalam boxed list):
|
|
890
|
+
* - desktop: QR selalu tampil (cara tercepat pindah ke HP);
|
|
891
|
+
* - baris link (mono, terpotong) + tombol salin & buka;
|
|
892
|
+
* - HP: "Share link" (Web Share API) dan QR dilipat di baris expander —
|
|
893
|
+
* pengguna sudah di HP, QR jarang dibutuhkan.
|
|
894
|
+
*/
|
|
905
895
|
function LiveAddress({ url }: { url: string }) {
|
|
906
896
|
const [copied, setCopied] = useState(false);
|
|
907
|
-
const [showQr, setShowQr] = useState(false);
|
|
908
897
|
const copy = async () => {
|
|
909
898
|
try {
|
|
910
899
|
await navigator.clipboard.writeText(url);
|
|
@@ -926,72 +915,58 @@ function LiveAddress({ url }: { url: string }) {
|
|
|
926
915
|
/* dibatalkan pengguna */
|
|
927
916
|
}
|
|
928
917
|
};
|
|
918
|
+
const host = url.replace(/^https?:\/\//, "");
|
|
929
919
|
|
|
930
920
|
return (
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
Mobile: disembunyikan (pengguna sudah di HP) kecuali diminta. */}
|
|
934
|
-
<div className={cn("justify-center sm:flex", showQr ? "flex" : "hidden")}>
|
|
921
|
+
<>
|
|
922
|
+
<li className="hidden justify-center py-5 sm:flex">
|
|
935
923
|
<QrCode value={url} label="QR code of your remote access link" className="size-40" />
|
|
936
|
-
</
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
924
|
+
</li>
|
|
925
|
+
<li className="flex min-h-14 items-center gap-2 py-2 pr-2 pl-4">
|
|
926
|
+
<a
|
|
927
|
+
href={url}
|
|
928
|
+
target="_blank"
|
|
929
|
+
rel="noopener noreferrer"
|
|
930
|
+
className="min-w-0 flex-1 truncate font-mono text-[14px] hover:underline"
|
|
931
|
+
title={url}
|
|
932
|
+
>
|
|
933
|
+
{host}
|
|
934
|
+
</a>
|
|
935
|
+
<Button
|
|
936
|
+
type="button"
|
|
937
|
+
variant="ghost"
|
|
938
|
+
size="icon-sm"
|
|
939
|
+
className="rounded-full"
|
|
940
|
+
aria-label={copied ? "Copied" : "Copy link"}
|
|
941
|
+
onClick={() => void copy()}
|
|
942
|
+
>
|
|
943
|
+
{copied ? <CheckIcon className="text-emerald-600 dark:text-emerald-400" /> : <CopyIcon />}
|
|
944
|
+
</Button>
|
|
945
|
+
<Button asChild variant="ghost" size="icon-sm" className="rounded-full">
|
|
946
|
+
<a href={url} target="_blank" rel="noopener noreferrer" aria-label="Open link in new tab">
|
|
947
|
+
<ExternalLinkIcon />
|
|
950
948
|
</a>
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
aria-label="Open link in new tab"
|
|
970
|
-
>
|
|
971
|
-
<ExternalLinkIcon />
|
|
972
|
-
</a>
|
|
973
|
-
</Button>
|
|
974
|
-
</div>
|
|
975
|
-
<div className="flex flex-wrap gap-2 sm:hidden">
|
|
976
|
-
{canShare && (
|
|
977
|
-
<Button type="button" size="sm" onClick={() => void share()}>
|
|
978
|
-
<Share2Icon data-icon="inline-start" />
|
|
979
|
-
Share link
|
|
980
|
-
</Button>
|
|
981
|
-
)}
|
|
982
|
-
<Button
|
|
983
|
-
type="button"
|
|
984
|
-
variant="outline"
|
|
985
|
-
size="sm"
|
|
986
|
-
aria-expanded={showQr}
|
|
987
|
-
onClick={() => setShowQr((v) => !v)}
|
|
988
|
-
>
|
|
989
|
-
<QrCodeIcon data-icon="inline-start" />
|
|
990
|
-
{showQr ? "Hide QR code" : "Show QR code"}
|
|
991
|
-
</Button>
|
|
949
|
+
</Button>
|
|
950
|
+
</li>
|
|
951
|
+
{canShare && (
|
|
952
|
+
<ActionRow
|
|
953
|
+
className="sm:hidden"
|
|
954
|
+
prefix={<RowGlyph icon={<Share2Icon className="size-[18px]" aria-hidden />} />}
|
|
955
|
+
title="Share link"
|
|
956
|
+
onActivate={() => void share()}
|
|
957
|
+
/>
|
|
958
|
+
)}
|
|
959
|
+
<ExpanderRow
|
|
960
|
+
className="sm:hidden"
|
|
961
|
+
prefix={<RowGlyph icon={<QrCodeIcon className="size-[18px]" aria-hidden />} />}
|
|
962
|
+
title="QR code"
|
|
963
|
+
subtitle="Scan from another device"
|
|
964
|
+
>
|
|
965
|
+
<div className="flex justify-center py-2">
|
|
966
|
+
<QrCode value={url} label="QR code of your remote access link" className="size-44" />
|
|
992
967
|
</div>
|
|
993
|
-
</
|
|
994
|
-
|
|
968
|
+
</ExpanderRow>
|
|
969
|
+
</>
|
|
995
970
|
);
|
|
996
971
|
}
|
|
997
972
|
|