kcg-code 0.1.0 → 0.2.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/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 +876 -159
- package/src/cli/index.ts +10 -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
|
@@ -1,67 +1,130 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Halaman Settings (`/settings/:section`) —
|
|
3
|
-
* (libadwaita)
|
|
4
|
-
* Tiap bagian punya URL sendiri
|
|
2
|
+
* Halaman Settings (`/settings/:section`) — navigasi ala GNOME Settings
|
|
3
|
+
* (AdwNavigationSplitView, libadwaita). Isi tiap bagian memakai "boxed list"
|
|
4
|
+
* (`components/settings/prefs`). Tiap bagian punya URL sendiri:
|
|
5
5
|
*
|
|
6
|
-
* - Profile (`/settings`, `/settings/profile`): avatar, nickname,
|
|
7
|
-
* KCG Code (username/email dari akun tunnel).
|
|
6
|
+
* - Profile (`/settings`, `/settings/profile`): avatar, nickname, akun.
|
|
8
7
|
* - Security (`/settings/security`): kunci aplikasi, auto-lock, passkey, dan
|
|
9
8
|
* perangkat yang login (`/settings/devices` lama -> grup ini).
|
|
10
9
|
* - Workspace (`/settings/workspace`): folder tempat semua Project berada.
|
|
11
10
|
* - Remote access (`/settings/remote`): tunnel publik lewat Google sign-in.
|
|
11
|
+
* - About KCG Code (`/settings/about`): versi & kredit — bukan pengaturan,
|
|
12
|
+
* jadi tampil terpisah di akhir navigasi (seperti "About" GNOME Settings).
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* Tata letak:
|
|
15
|
+
* - Lebar (lg+): daftar navigasi di kiri (sticky), isi bagian di kanan.
|
|
16
|
+
* - Sempit: `/settings` = daftar bagian (baris dengan ikon, deskripsi,
|
|
17
|
+
* chevron); bagian dibuka sebagai halaman dengan tombol kembali
|
|
18
|
+
* "‹ Settings" + judul di tengah (AdwNavigationView).
|
|
15
19
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import { type ComponentType, useCallback, useEffect } from "react";
|
|
20
|
+
import { ChevronLeftIcon, FolderIcon, GlobeIcon, ShieldIcon, UserIcon } from "lucide-react";
|
|
21
|
+
import { type ComponentType, type ReactNode, useCallback, useEffect } from "react";
|
|
18
22
|
import { toast } from "sonner";
|
|
19
23
|
import { AvatarEditor } from "@/components/auth/AvatarEditor";
|
|
24
|
+
import { ProfileAvatar } from "@/components/auth/ProfileAvatar";
|
|
20
25
|
import { AboutGroup } from "@/components/settings/AboutGroup";
|
|
21
26
|
import { AccountGroup } from "@/components/settings/AccountGroup";
|
|
22
|
-
import { EntryRow, PrefsGroup } from "@/components/settings/prefs";
|
|
27
|
+
import { ActionRow, EntryRow, PrefsGroup } from "@/components/settings/prefs";
|
|
23
28
|
import { RemoteAccessGroup } from "@/components/settings/RemoteAccessGroup";
|
|
24
29
|
import { SecurityGroup } from "@/components/settings/SecurityGroup";
|
|
25
30
|
import { WorkspaceGroup } from "@/components/settings/WorkspaceGroup";
|
|
26
31
|
import { Spinner } from "@/components/ui/spinner";
|
|
32
|
+
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
27
33
|
import { useRouter } from "@/hooks/useRouter";
|
|
28
34
|
import { ApiError, apiFetch } from "@/lib/api";
|
|
29
35
|
import { refreshAuth, useAuth } from "@/lib/auth";
|
|
30
36
|
import { type SettingsSection, settingsPath } from "@/lib/routes";
|
|
31
37
|
import { cn } from "@/lib/utils";
|
|
32
38
|
import type { AuthStatus } from "@/server/services/auth";
|
|
39
|
+
import logo from "../../logo.svg";
|
|
33
40
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
/** Logo aplikasi sebagai ikon navigasi "About KCG Code". */
|
|
42
|
+
function AppIcon({ className }: { className?: string }) {
|
|
43
|
+
return <img src={logo} alt="" className={cn("rounded-[22%]", className)} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SettingsPageInfo {
|
|
47
|
+
id: SettingsSection;
|
|
37
48
|
label: string;
|
|
38
|
-
/**
|
|
39
|
-
|
|
49
|
+
/** Subjudul baris di daftar bagian (layar sempit). */
|
|
50
|
+
description: string;
|
|
40
51
|
icon: ComponentType<{ className?: string }>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Bagian pengaturan (urutan navigasi). About sengaja terpisah. */
|
|
55
|
+
export const SETTINGS_PANELS: readonly SettingsPageInfo[] = [
|
|
56
|
+
{
|
|
57
|
+
id: "profile",
|
|
58
|
+
label: "Profile",
|
|
59
|
+
description: "Name, picture & account",
|
|
60
|
+
icon: UserIcon,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "security",
|
|
64
|
+
label: "Security",
|
|
65
|
+
description: "App lock, passkeys & devices",
|
|
66
|
+
icon: ShieldIcon,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "workspace",
|
|
70
|
+
label: "Workspace",
|
|
71
|
+
description: "Where your projects live",
|
|
72
|
+
icon: FolderIcon,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "remote",
|
|
76
|
+
label: "Remote access",
|
|
77
|
+
description: "Open KCG Code from anywhere",
|
|
78
|
+
icon: GlobeIcon,
|
|
79
|
+
},
|
|
46
80
|
];
|
|
47
81
|
|
|
82
|
+
/** Entri "About" — selalu terakhir, dipisah dari bagian pengaturan. */
|
|
83
|
+
export const SETTINGS_ABOUT: SettingsPageInfo = {
|
|
84
|
+
id: "about",
|
|
85
|
+
label: "About KCG Code",
|
|
86
|
+
description: "Version & credits",
|
|
87
|
+
icon: AppIcon,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function pageInfo(section: SettingsSection): SettingsPageInfo {
|
|
91
|
+
return SETTINGS_PANELS.find((p) => p.id === section) ?? SETTINGS_ABOUT;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Breakpoint `lg` — di atasnya navigasi & isi berdampingan. */
|
|
95
|
+
const WIDE_QUERY = "(min-width: 1024px)";
|
|
96
|
+
|
|
48
97
|
function errorText(e: unknown, fallback: string): string {
|
|
49
98
|
return e instanceof ApiError ? e.message : fallback;
|
|
50
99
|
}
|
|
51
100
|
|
|
101
|
+
/** Tautan SPA (Ctrl/Cmd-klik tetap membuka tab baru). */
|
|
102
|
+
function useNavLink() {
|
|
103
|
+
const { navigate } = useRouter();
|
|
104
|
+
return (href: string) => (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
105
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
navigate(href);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
52
111
|
export function SettingsPage({
|
|
53
112
|
section,
|
|
54
113
|
anchor,
|
|
114
|
+
root = false,
|
|
55
115
|
}: {
|
|
56
116
|
section: SettingsSection;
|
|
57
117
|
/** Grup yang dituju (mis. URL lama `/settings/devices` -> "devices"). */
|
|
58
118
|
anchor?: string;
|
|
119
|
+
/** `/settings` tanpa sub-halaman (layar sempit: daftar bagian). */
|
|
120
|
+
root?: boolean;
|
|
59
121
|
}) {
|
|
60
122
|
const auth = useAuth();
|
|
61
123
|
const status = auth.phase === "ready" ? auth.status : null;
|
|
62
124
|
const ok = useCallback((text: string) => toast.success(text), []);
|
|
63
125
|
const error = useCallback((text: string) => toast.error(text), []);
|
|
64
126
|
const ready = status !== null;
|
|
127
|
+
const wide = useMediaQuery(WIDE_QUERY);
|
|
65
128
|
|
|
66
129
|
// Gulir ke grup tujuan setelah kontennya dirender.
|
|
67
130
|
useEffect(() => {
|
|
@@ -72,25 +135,42 @@ export function SettingsPage({
|
|
|
72
135
|
return () => clearTimeout(t);
|
|
73
136
|
}, [anchor, ready]);
|
|
74
137
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
138
|
+
const content = !status ? (
|
|
139
|
+
<div className="flex justify-center py-16" role="status">
|
|
140
|
+
<Spinner className="size-6 text-muted-foreground" />
|
|
141
|
+
<span className="sr-only">Loading settings…</span>
|
|
142
|
+
</div>
|
|
143
|
+
) : (
|
|
144
|
+
<div className="flex flex-col gap-8">
|
|
145
|
+
{section === "profile" && <ProfileSection status={status} onOk={ok} onError={error} />}
|
|
146
|
+
{section === "security" && <SecurityGroup status={status} onOk={ok} />}
|
|
147
|
+
{section === "workspace" && <WorkspaceGroup status={status} />}
|
|
148
|
+
{section === "remote" && <RemoteAccessGroup protectedApp={status.protected} />}
|
|
149
|
+
{section === "about" && <AboutGroup />}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
79
152
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
{section === "security" && <SecurityGroup status={status} onOk={ok} />}
|
|
89
|
-
{section === "workspace" && <WorkspaceGroup status={status} />}
|
|
90
|
-
{section === "remote" && <RemoteAccessGroup protectedApp={status.protected} />}
|
|
91
|
-
{section === "about" && <AboutGroup />}
|
|
153
|
+
// Lebar: AdwNavigationSplitView — sidebar navigasi + isi.
|
|
154
|
+
if (wide) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="grid grid-cols-[14.5rem_minmax(0,1fr)] gap-10 pt-2 pb-10">
|
|
157
|
+
<SettingsSidebar current={section} />
|
|
158
|
+
<div className="flex min-w-0 max-w-[40rem] flex-col gap-6">
|
|
159
|
+
<h2 className="px-1 text-2xl font-semibold tracking-tight">{pageInfo(section).label}</h2>
|
|
160
|
+
{content}
|
|
92
161
|
</div>
|
|
93
|
-
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Sempit, `/settings`: daftar bagian.
|
|
167
|
+
if (root) return <SettingsIndex status={status} />;
|
|
168
|
+
|
|
169
|
+
// Sempit, sub-halaman: tombol kembali + judul di tengah.
|
|
170
|
+
return (
|
|
171
|
+
<div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[max(2.5rem,env(safe-area-inset-bottom))]">
|
|
172
|
+
<SubpageHeader title={pageInfo(section).label} />
|
|
173
|
+
{content}
|
|
94
174
|
</div>
|
|
95
175
|
);
|
|
96
176
|
}
|
|
@@ -98,120 +178,107 @@ export function SettingsPage({
|
|
|
98
178
|
// ---------------------------------------------------------------- nav ----
|
|
99
179
|
|
|
100
180
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* - sm+: deretan pil (4) di bawah judul.
|
|
105
|
-
* - Mobile: bottom navigation (4 kolom, ala Material 3 NavigationBar).
|
|
181
|
+
* Sidebar navigasi (layar lebar) — gaya `.navigation-sidebar` libadwaita:
|
|
182
|
+
* baris ikon + label, pilihan aktif berlatar membulat. About di bawah garis
|
|
183
|
+
* pemisah, dengan logo aplikasi sebagai ikon.
|
|
106
184
|
*/
|
|
107
|
-
function
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
185
|
+
function SettingsSidebar({ current }: { current: SettingsSection }) {
|
|
186
|
+
const link = useNavLink();
|
|
187
|
+
const item = ({ id, label, icon: Icon }: SettingsPageInfo) => {
|
|
188
|
+
const href = settingsPath(id);
|
|
189
|
+
const active = id === current;
|
|
190
|
+
return (
|
|
191
|
+
<li key={id}>
|
|
192
|
+
<a
|
|
193
|
+
href={href}
|
|
194
|
+
onClick={link(href)}
|
|
195
|
+
aria-current={active ? "page" : undefined}
|
|
196
|
+
className={cn(
|
|
197
|
+
"flex h-10 items-center gap-3 rounded-lg px-3 text-[15px] transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
198
|
+
active
|
|
199
|
+
? "bg-black/[0.08] font-medium text-foreground dark:bg-white/[0.1]"
|
|
200
|
+
: "text-foreground/80 hover:bg-black/[0.04] hover:text-foreground dark:hover:bg-white/[0.05]",
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
<Icon className="size-[18px] shrink-0" />
|
|
204
|
+
<span className="truncate">{label}</span>
|
|
205
|
+
</a>
|
|
206
|
+
</li>
|
|
207
|
+
);
|
|
113
208
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
209
|
+
return (
|
|
210
|
+
<nav aria-label="Settings" className="sticky top-2 flex flex-col gap-3 self-start">
|
|
211
|
+
<h1 className="px-3 text-2xl font-semibold tracking-tight">Settings</h1>
|
|
212
|
+
<ul className="flex flex-col gap-0.5">{SETTINGS_PANELS.map(item)}</ul>
|
|
213
|
+
<div className="mx-3 h-px bg-border" aria-hidden />
|
|
214
|
+
<ul className="flex flex-col gap-0.5">{item(SETTINGS_ABOUT)}</ul>
|
|
215
|
+
</nav>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Daftar bagian (layar sempit, `/settings`) — boxed list ala GNOME Settings
|
|
221
|
+
* mobile: profil di atas (avatar + nama), lalu bagian pengaturan, lalu
|
|
222
|
+
* About di grup sendiri.
|
|
223
|
+
*/
|
|
224
|
+
function SettingsIndex({ status }: { status: AuthStatus | null }) {
|
|
225
|
+
const { navigate } = useRouter();
|
|
226
|
+
const profile = status?.profile;
|
|
227
|
+
const row = (p: SettingsPageInfo, overrides?: { title?: ReactNode; prefix?: ReactNode }) => (
|
|
228
|
+
<ActionRow
|
|
229
|
+
key={p.id}
|
|
230
|
+
prefix={overrides?.prefix ?? <p.icon className="size-[18px] text-muted-foreground" />}
|
|
231
|
+
title={overrides?.title ?? p.label}
|
|
232
|
+
subtitle={p.description}
|
|
233
|
+
onActivate={() => navigate(settingsPath(p.id))}
|
|
234
|
+
/>
|
|
235
|
+
);
|
|
236
|
+
const [profilePanel, ...panels] = SETTINGS_PANELS;
|
|
117
237
|
|
|
118
238
|
return (
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
>
|
|
145
|
-
<InfoIcon className="size-5" aria-hidden />
|
|
146
|
-
</a>
|
|
147
|
-
</div>
|
|
148
|
-
<nav aria-label="Settings sections" className="hidden sm:block">
|
|
149
|
-
<ul className="flex gap-1">
|
|
150
|
-
{SETTINGS_TABS.map(({ id, label, icon: Icon }) => {
|
|
151
|
-
const active = id === current;
|
|
152
|
-
const href = settingsPath(id);
|
|
153
|
-
return (
|
|
154
|
-
<li key={id} className="shrink-0">
|
|
155
|
-
<a
|
|
156
|
-
href={href}
|
|
157
|
-
aria-current={active ? "page" : undefined}
|
|
158
|
-
onClick={go(href)}
|
|
159
|
-
className={cn(
|
|
160
|
-
"flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
161
|
-
active
|
|
162
|
-
? "bg-primary text-primary-foreground"
|
|
163
|
-
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
164
|
-
)}
|
|
165
|
-
>
|
|
166
|
-
<Icon className="size-4 shrink-0" aria-hidden />
|
|
167
|
-
{label}
|
|
168
|
-
</a>
|
|
169
|
-
</li>
|
|
170
|
-
);
|
|
171
|
-
})}
|
|
172
|
-
</ul>
|
|
173
|
-
</nav>
|
|
174
|
-
</div>
|
|
239
|
+
<div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[max(2.5rem,env(safe-area-inset-bottom))]">
|
|
240
|
+
<h1 className="px-1 text-2xl font-semibold tracking-tight">Settings</h1>
|
|
241
|
+
{profilePanel && (
|
|
242
|
+
<PrefsGroup>
|
|
243
|
+
{row(profilePanel, {
|
|
244
|
+
title: profile?.nickname ?? "Profile",
|
|
245
|
+
prefix: (
|
|
246
|
+
<ProfileAvatar
|
|
247
|
+
nickname={profile?.nickname ?? null}
|
|
248
|
+
avatarUrl={profile?.avatarUrl ?? null}
|
|
249
|
+
avatarPreset={profile?.avatarPreset ?? null}
|
|
250
|
+
className="size-10"
|
|
251
|
+
textClassName="text-base"
|
|
252
|
+
/>
|
|
253
|
+
),
|
|
254
|
+
})}
|
|
255
|
+
</PrefsGroup>
|
|
256
|
+
)}
|
|
257
|
+
<PrefsGroup>{panels.map((p) => row(p))}</PrefsGroup>
|
|
258
|
+
<PrefsGroup>
|
|
259
|
+
{row(SETTINGS_ABOUT, { prefix: <AppIcon className="size-[22px]" /> })}
|
|
260
|
+
</PrefsGroup>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
175
264
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
265
|
+
/** Header sub-halaman (layar sempit): "‹ Settings" + judul di tengah. */
|
|
266
|
+
function SubpageHeader({ title }: { title: string }) {
|
|
267
|
+
const link = useNavLink();
|
|
268
|
+
const href = settingsPath();
|
|
269
|
+
return (
|
|
270
|
+
<header className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
|
|
271
|
+
<a
|
|
272
|
+
href={href}
|
|
273
|
+
onClick={link(href)}
|
|
274
|
+
className="-ml-2 flex w-fit items-center gap-0.5 rounded-full py-1.5 pr-3 pl-1.5 text-[15px] font-medium outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50 active:bg-muted"
|
|
179
275
|
>
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<a
|
|
187
|
-
href={href}
|
|
188
|
-
aria-current={active ? "page" : undefined}
|
|
189
|
-
aria-label={short ? label : undefined}
|
|
190
|
-
onClick={go(href)}
|
|
191
|
-
className={cn(
|
|
192
|
-
"group flex min-h-16 flex-col items-center justify-center gap-1 px-1 pt-2 pb-1.5 text-[11.5px] font-medium transition-colors outline-none",
|
|
193
|
-
active ? "text-foreground" : "text-muted-foreground active:text-foreground",
|
|
194
|
-
)}
|
|
195
|
-
>
|
|
196
|
-
{/* Pil indikator di belakang ikon untuk bagian aktif. */}
|
|
197
|
-
<span
|
|
198
|
-
className={cn(
|
|
199
|
-
"flex h-8 w-14 items-center justify-center rounded-full transition-colors group-focus-visible:ring-[3px] group-focus-visible:ring-ring/50",
|
|
200
|
-
active
|
|
201
|
-
? "bg-primary/15 text-primary dark:bg-primary/25"
|
|
202
|
-
: "group-active:bg-muted",
|
|
203
|
-
)}
|
|
204
|
-
>
|
|
205
|
-
<Icon className="size-5 shrink-0" aria-hidden />
|
|
206
|
-
</span>
|
|
207
|
-
<span className="max-w-full truncate">{short ?? label}</span>
|
|
208
|
-
</a>
|
|
209
|
-
</li>
|
|
210
|
-
);
|
|
211
|
-
})}
|
|
212
|
-
</ul>
|
|
213
|
-
</nav>
|
|
214
|
-
</>
|
|
276
|
+
<ChevronLeftIcon className="size-5" aria-hidden />
|
|
277
|
+
Settings
|
|
278
|
+
</a>
|
|
279
|
+
<h1 className="truncate text-center text-[17px] font-bold">{title}</h1>
|
|
280
|
+
<span aria-hidden />
|
|
281
|
+
</header>
|
|
215
282
|
);
|
|
216
283
|
}
|
|
217
284
|
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Navigasi Settings —
|
|
2
|
+
* Navigasi Settings — bagian pengaturan dibatasi (tidak sesak), About
|
|
3
|
+
* terpisah di akhir navigasi.
|
|
3
4
|
*/
|
|
4
5
|
import { expect, test } from "bun:test";
|
|
5
|
-
import {
|
|
6
|
+
import { SETTINGS_SECTIONS } from "@/lib/routes";
|
|
7
|
+
import { SETTINGS_ABOUT, SETTINGS_PANELS } from "../SettingsPage";
|
|
6
8
|
|
|
7
|
-
test("
|
|
8
|
-
expect(
|
|
9
|
+
test("empat bagian pengaturan: Devices bagian dari Security", () => {
|
|
10
|
+
expect(SETTINGS_PANELS.map((p) => p.id)).toEqual(["profile", "security", "workspace", "remote"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("About entri terpisah; navigasi mencakup semua sub-halaman", () => {
|
|
14
|
+
expect(SETTINGS_ABOUT.id).toBe("about");
|
|
15
|
+
expect([...SETTINGS_PANELS.map((p) => p.id), SETTINGS_ABOUT.id]).toEqual([...SETTINGS_SECTIONS]);
|
|
9
16
|
});
|