kcg-code 0.0.0-stage → 0.1.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 (286) hide show
  1. package/README.md +228 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +83 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Halaman Session view (`/projects/:projectId/sessions/:sessionId`).
3
+ *
4
+ * - `Session` di-resolve dari id di URL via `GET /api/sessions` (Requirement
5
+ * 1.5) sehingga halaman dapat di-refresh / diakses langsung (direct link)
6
+ * tanpa state dari halaman sebelumnya.
7
+ * - Session tidak ditemukan (sudah dihapus / id salah) -> pesan error dengan
8
+ * aksi kembali ke daftar Session Project terkait.
9
+ * - Render memakai `SessionView` yang sudah ada; dibungkus container yang
10
+ * sama dengan halaman lain.
11
+ */
12
+
13
+ import { useCallback, useEffect, useState } from "react";
14
+ import {
15
+ SessionComposerSkeleton,
16
+ SessionTimelineSkeleton,
17
+ } from "@/components/sessions/SessionTimelineSkeleton";
18
+ import { SessionView } from "@/components/sessions/SessionView";
19
+ import { Skeleton } from "@/components/ui/skeleton";
20
+ import { useRouter } from "@/hooks/useRouter";
21
+ import { ApiError, apiErrorMessage, apiFetch } from "@/lib/api";
22
+ import { projectPath } from "@/lib/routes";
23
+ import { clearSeededSession, peekSeededSession } from "@/lib/session-handoff";
24
+ import type { Session } from "@/types";
25
+
26
+ export interface SessionPageProps {
27
+ projectId: string;
28
+ sessionId: string;
29
+ }
30
+
31
+ type LoadState =
32
+ | { phase: "loading" }
33
+ | { phase: "error"; code: string }
34
+ | { phase: "ready"; session: Session };
35
+
36
+ export function SessionPage({ projectId, sessionId }: SessionPageProps) {
37
+ const { navigate } = useRouter();
38
+ // Baru dibuat dari homepage? Render langsung dari data titipan (tanpa
39
+ // "Loading…") agar View Transition punya halaman tujuan yang utuh.
40
+ const [state, setState] = useState<LoadState>(() => {
41
+ const seeded = peekSeededSession(sessionId);
42
+ return seeded && seeded.projectId === projectId
43
+ ? { phase: "ready", session: seeded }
44
+ : { phase: "loading" };
45
+ });
46
+
47
+ const load = useCallback(async () => {
48
+ if (peekSeededSession(sessionId)?.projectId === projectId) {
49
+ clearSeededSession(sessionId);
50
+ return;
51
+ }
52
+ setState({ phase: "loading" });
53
+ try {
54
+ const res = await apiFetch("/api/sessions");
55
+ const body = (await res.json()) as { sessions: Session[] };
56
+ const session = body.sessions.find((s) => s.id === sessionId && s.projectId === projectId);
57
+ if (session === undefined) {
58
+ setState({ phase: "error", code: "SESSION_NOT_FOUND" });
59
+ } else {
60
+ setState({ phase: "ready", session });
61
+ }
62
+ } catch (e) {
63
+ setState({
64
+ phase: "error",
65
+ code: e instanceof ApiError ? e.code : "INTERNAL_ERROR",
66
+ });
67
+ }
68
+ }, [projectId, sessionId]);
69
+
70
+ useEffect(() => {
71
+ void load();
72
+ }, [load]);
73
+
74
+ if (state.phase === "loading") {
75
+ // Kerangka setara SessionView (header + timeline + composer) — bagian
76
+ // timeline & composer SAMA dengan yang dipakai SessionView saat menunggu
77
+ // riwayat, jadi peralihan dua fase loading tidak berkedip.
78
+ return (
79
+ <div className="flex h-full min-h-0 flex-col" aria-busy="true">
80
+ <div className="flex shrink-0 items-center gap-2 px-3 py-3 sm:px-4">
81
+ <Skeleton className="size-9 rounded-md" />
82
+ <div className="flex flex-col gap-1.5">
83
+ <Skeleton className="h-4 w-40" />
84
+ <Skeleton className="hidden h-3 w-56 sm:block" />
85
+ </div>
86
+ </div>
87
+ <SessionTimelineSkeleton />
88
+ <SessionComposerSkeleton />
89
+ </div>
90
+ );
91
+ }
92
+
93
+ if (state.phase === "error") {
94
+ return (
95
+ <div className="flex flex-col items-center gap-4 py-10 text-center">
96
+ <p className="text-sm text-muted-foreground">{apiErrorMessage(state.code)}</p>
97
+ <a
98
+ href={projectPath(projectId)}
99
+ onClick={(e) => {
100
+ e.preventDefault();
101
+ navigate(projectPath(projectId));
102
+ }}
103
+ className="text-sm font-medium underline underline-offset-4"
104
+ >
105
+ Back to sessions
106
+ </a>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ return (
112
+ <main className="min-h-0 flex-1">
113
+ <SessionView
114
+ key={state.session.id}
115
+ session={state.session}
116
+ onBack={() => navigate(projectPath(projectId))}
117
+ // Session sudah tidak ada — kembali ke daftar Session Project.
118
+ onDeleted={() => navigate(projectPath(projectId))}
119
+ />
120
+ </main>
121
+ );
122
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Halaman Settings (`/settings/:section`) — tata letak ala GNOME Settings
3
+ * (libadwaita): kolom sempit di tengah, grup berjudul berisi "boxed list".
4
+ * Tiap bagian punya URL sendiri. EMPAT tab (muat di desktop & bottom nav HP):
5
+ *
6
+ * - Profile (`/settings`, `/settings/profile`): avatar, nickname, dan akun
7
+ * KCG Code (username/email dari akun tunnel).
8
+ * - Security (`/settings/security`): kunci aplikasi, auto-lock, passkey, dan
9
+ * perangkat yang login (`/settings/devices` lama -> grup ini).
10
+ * - Workspace (`/settings/workspace`): folder tempat semua Project berada.
11
+ * - Remote access (`/settings/remote`): tunnel publik lewat Google sign-in.
12
+ *
13
+ * About (`/settings/about`) bukan pengaturan — dibuka dari tombol ⓘ di
14
+ * samping judul, bukan tab.
15
+ */
16
+ import { ArrowLeftIcon, FolderIcon, GlobeIcon, InfoIcon, ShieldIcon, UserIcon } from "lucide-react";
17
+ import { type ComponentType, useCallback, useEffect } from "react";
18
+ import { toast } from "sonner";
19
+ import { AvatarEditor } from "@/components/auth/AvatarEditor";
20
+ import { AboutGroup } from "@/components/settings/AboutGroup";
21
+ import { AccountGroup } from "@/components/settings/AccountGroup";
22
+ import { EntryRow, PrefsGroup } from "@/components/settings/prefs";
23
+ import { RemoteAccessGroup } from "@/components/settings/RemoteAccessGroup";
24
+ import { SecurityGroup } from "@/components/settings/SecurityGroup";
25
+ import { WorkspaceGroup } from "@/components/settings/WorkspaceGroup";
26
+ import { Spinner } from "@/components/ui/spinner";
27
+ import { useRouter } from "@/hooks/useRouter";
28
+ import { ApiError, apiFetch } from "@/lib/api";
29
+ import { refreshAuth, useAuth } from "@/lib/auth";
30
+ import { type SettingsSection, settingsPath } from "@/lib/routes";
31
+ import { cn } from "@/lib/utils";
32
+ import type { AuthStatus } from "@/server/services/auth";
33
+
34
+ /** Tab Settings — sengaja dibatasi empat agar tidak sesak. */
35
+ export const SETTINGS_TABS: {
36
+ id: Exclude<SettingsSection, "about">;
37
+ label: string;
38
+ /** Label ringkas untuk bottom nav mobile. */
39
+ short?: string;
40
+ icon: ComponentType<{ className?: string }>;
41
+ }[] = [
42
+ { id: "profile", label: "Profile", icon: UserIcon },
43
+ { id: "security", label: "Security", icon: ShieldIcon },
44
+ { id: "workspace", label: "Workspace", icon: FolderIcon },
45
+ { id: "remote", label: "Remote access", short: "Remote", icon: GlobeIcon },
46
+ ];
47
+
48
+ function errorText(e: unknown, fallback: string): string {
49
+ return e instanceof ApiError ? e.message : fallback;
50
+ }
51
+
52
+ export function SettingsPage({
53
+ section,
54
+ anchor,
55
+ }: {
56
+ section: SettingsSection;
57
+ /** Grup yang dituju (mis. URL lama `/settings/devices` -> "devices"). */
58
+ anchor?: string;
59
+ }) {
60
+ const auth = useAuth();
61
+ const status = auth.phase === "ready" ? auth.status : null;
62
+ const ok = useCallback((text: string) => toast.success(text), []);
63
+ const error = useCallback((text: string) => toast.error(text), []);
64
+ const ready = status !== null;
65
+
66
+ // Gulir ke grup tujuan setelah kontennya dirender.
67
+ useEffect(() => {
68
+ if (!anchor || !ready) return;
69
+ const t = setTimeout(() => {
70
+ document.getElementById(anchor)?.scrollIntoView({ block: "start", behavior: "smooth" });
71
+ }, 100);
72
+ return () => clearTimeout(t);
73
+ }, [anchor, ready]);
74
+
75
+ return (
76
+ // Mobile: ruang ekstra di bawah agar konten tidak tertutup bottom nav.
77
+ <div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[calc(5rem+env(safe-area-inset-bottom))] sm:pb-10">
78
+ <SettingsNav current={section} />
79
+
80
+ {!status ? (
81
+ <div className="flex justify-center py-16" role="status">
82
+ <Spinner className="size-6 text-muted-foreground" />
83
+ <span className="sr-only">Loading settings…</span>
84
+ </div>
85
+ ) : (
86
+ <div className="flex flex-col gap-8">
87
+ {section === "profile" && <ProfileSection status={status} onOk={ok} onError={error} />}
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 />}
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ // ---------------------------------------------------------------- nav ----
99
+
100
+ /**
101
+ * Navigasi bagian Settings.
102
+ * - Judul + tombol About (ⓘ) di kanannya. Di halaman About, judul berubah
103
+ * jadi tombol kembali — About bukan tab.
104
+ * - sm+: deretan pil (4) di bawah judul.
105
+ * - Mobile: bottom navigation (4 kolom, ala Material 3 NavigationBar).
106
+ */
107
+ function SettingsNav({ current }: { current: SettingsSection }) {
108
+ const { navigate } = useRouter();
109
+ const go = (href: string) => (e: React.MouseEvent<HTMLAnchorElement>) => {
110
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
111
+ e.preventDefault();
112
+ navigate(href);
113
+ };
114
+ const onAbout = current === "about";
115
+ const aboutHref = settingsPath("about");
116
+ const backHref = settingsPath("profile");
117
+
118
+ return (
119
+ <>
120
+ <div className="flex flex-col gap-3">
121
+ <div className="flex items-center justify-between gap-3 px-1">
122
+ {onAbout ? (
123
+ <a
124
+ href={backHref}
125
+ onClick={go(backHref)}
126
+ className="-ml-2 flex items-center gap-2 rounded-full py-1 pr-3 pl-2 text-2xl font-semibold tracking-tight outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
127
+ >
128
+ <ArrowLeftIcon className="size-5" aria-hidden />
129
+ <span>Settings</span>
130
+ </a>
131
+ ) : (
132
+ <h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
133
+ )}
134
+ <a
135
+ href={aboutHref}
136
+ onClick={go(aboutHref)}
137
+ aria-label="About KCG Code"
138
+ aria-current={onAbout ? "page" : undefined}
139
+ title="About KCG Code"
140
+ className={cn(
141
+ "flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50",
142
+ onAbout && "bg-muted text-foreground",
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>
175
+
176
+ <nav
177
+ aria-label="Settings sections"
178
+ className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:hidden"
179
+ >
180
+ <ul className="mx-auto grid max-w-md grid-cols-4">
181
+ {SETTINGS_TABS.map(({ id, label, short, icon: Icon }) => {
182
+ const active = id === current;
183
+ const href = settingsPath(id);
184
+ return (
185
+ <li key={id} className="min-w-0">
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
+ </>
215
+ );
216
+ }
217
+
218
+ // ----------------------------------------------------------- profile ----
219
+
220
+ function ProfileSection({
221
+ status,
222
+ onOk,
223
+ onError,
224
+ }: {
225
+ status: AuthStatus;
226
+ onOk: (t: string) => void;
227
+ onError: (t: string) => void;
228
+ }) {
229
+ const saveNickname = async (next: string): Promise<string | null> => {
230
+ try {
231
+ await apiFetch("/api/auth/profile", {
232
+ method: "PATCH",
233
+ body: JSON.stringify({ nickname: next || null }),
234
+ });
235
+ await refreshAuth();
236
+ onOk("Nickname saved.");
237
+ return null;
238
+ } catch (err) {
239
+ return errorText(err, "Couldn't save nickname.");
240
+ }
241
+ };
242
+
243
+ return (
244
+ <>
245
+ <div className="flex flex-col gap-5">
246
+ <div className="flex flex-col items-center gap-3 pt-1 text-center">
247
+ <AvatarEditor
248
+ profile={status.profile}
249
+ onResult={(r) => ("ok" in r ? onOk(r.ok) : onError(r.error))}
250
+ />
251
+ <h2 className="max-w-full truncate px-2 text-xl font-semibold tracking-tight sm:text-2xl">
252
+ {status.profile.nickname ?? "Your profile"}
253
+ </h2>
254
+ </div>
255
+
256
+ <PrefsGroup
257
+ title="Profile"
258
+ description="Your name and picture appear on the lock screen and sidebar."
259
+ >
260
+ <EntryRow
261
+ title="Nickname"
262
+ value={status.profile.nickname ?? ""}
263
+ placeholder="Add a nickname"
264
+ maxLength={40}
265
+ autoComplete="nickname"
266
+ onSave={saveNickname}
267
+ />
268
+ </PrefsGroup>
269
+ </div>
270
+
271
+ <AccountGroup />
272
+ </>
273
+ );
274
+ }
275
+
276
+ /** Tautan antar sub-halaman Settings. */
277
+ export function SectionLink({
278
+ section,
279
+ children,
280
+ }: {
281
+ section: SettingsSection;
282
+ children: React.ReactNode;
283
+ }) {
284
+ const { navigate } = useRouter();
285
+ const href = settingsPath(section);
286
+ return (
287
+ <a
288
+ href={href}
289
+ onClick={(e) => {
290
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
291
+ e.preventDefault();
292
+ navigate(href);
293
+ }}
294
+ className="font-medium text-foreground underline underline-offset-2"
295
+ >
296
+ {children}
297
+ </a>
298
+ );
299
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Navigasi Settings — jumlah tab dibatasi (tidak sesak).
3
+ */
4
+ import { expect, test } from "bun:test";
5
+ import { SETTINGS_TABS } from "../SettingsPage";
6
+
7
+ test("hanya empat tab: Devices bagian dari Security, About bukan tab", () => {
8
+ expect(SETTINGS_TABS.map((t) => t.id)).toEqual(["profile", "security", "workspace", "remote"]);
9
+ });