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,100 @@
1
+ /**
2
+ * Theme terminal untuk dashboard kcgcode.
3
+ * Palet selaras dark mode app — cyan/violet accent.
4
+ */
5
+ import pc from "picocolors";
6
+
7
+ const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined;
8
+ const paint = (fn: (s: string) => string) => (useColor ? fn : (s: string) => s);
9
+
10
+ export const c = {
11
+ bold: paint(pc.bold),
12
+ dim: paint(pc.dim),
13
+ cyan: paint(pc.cyan),
14
+ cyanBright: paint((s) => pc.bold(pc.cyan(s))),
15
+ violet: paint(pc.magenta),
16
+ violetBright: paint((s) => pc.bold(pc.magenta(s))),
17
+ green: paint(pc.green),
18
+ greenBright: paint((s) => pc.bold(pc.green(s))),
19
+ yellow: paint(pc.yellow),
20
+ yellowBright: paint((s) => pc.bold(pc.yellow(s))),
21
+ red: paint(pc.red),
22
+ redBright: paint((s) => pc.bold(pc.red(s))),
23
+ white: paint(pc.white),
24
+ gray: paint(pc.gray),
25
+ underline: paint(pc.underline),
26
+ };
27
+
28
+ export const symbols = {
29
+ bullet: "●",
30
+ idle: "○",
31
+ arrow: "→",
32
+ check: "✓",
33
+ cross: "✗",
34
+ warn: "!",
35
+ boxH: "─",
36
+ boxV: "│",
37
+ boxTL: "┌",
38
+ boxTR: "┐",
39
+ boxBL: "└",
40
+ boxBR: "┘",
41
+ } as const;
42
+
43
+ /** Versi paket dari package.json. */
44
+ export async function readPackageVersion(): Promise<string> {
45
+ try {
46
+ const file = Bun.file(new URL("../../package.json", import.meta.url));
47
+ if (!(await file.exists())) return "0.1.0";
48
+ const json = (await file.json()) as { version?: string };
49
+ return json.version ?? "0.1.0";
50
+ } catch {
51
+ return "0.1.0";
52
+ }
53
+ }
54
+
55
+ /** Banner ASCII "KCG". */
56
+ export function logoLines(): string[] {
57
+ return [
58
+ "██╗ ██╗ ██████╗ ",
59
+ "██║ ██╔╝██╔════╝ ",
60
+ "█████╔╝ ██║ ",
61
+ "██╔═██╗ ██║ ",
62
+ "██║ ██╗╚██████╗ ",
63
+ "╚═╝ ╚═╝ ╚═════╝ ",
64
+ ];
65
+ }
66
+
67
+ export function stripAnsi(s: string): string {
68
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strip ANSI SGR
69
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
70
+ }
71
+
72
+ /** Render panel berborder. Lebar menyesuaikan isi. */
73
+ export function panel(title: string | null, lines: string[], minWidth = 48): string {
74
+ const contentW = Math.max(
75
+ title ? title.length + 2 : 0,
76
+ ...lines.map((l) => stripAnsi(l).length),
77
+ minWidth - 4,
78
+ );
79
+ const inner = Math.max(20, contentW + 2);
80
+ const out: string[] = [];
81
+ const titleText = title ? ` ${title} ` : "";
82
+ const pad = Math.max(0, inner - titleText.length);
83
+ const left = Math.floor(pad / 2);
84
+ const right = pad - left;
85
+ out.push(
86
+ `${c.cyan(symbols.boxTL)}${c.cyan(symbols.boxH.repeat(left) + titleText + symbols.boxH.repeat(right))}${c.cyan(symbols.boxTR)}`,
87
+ );
88
+ for (const line of lines) {
89
+ const visible = stripAnsi(line).length;
90
+ const spaces = Math.max(0, inner - visible - 2);
91
+ out.push(`${c.cyan(symbols.boxV)} ${line}${" ".repeat(spaces)} ${c.cyan(symbols.boxV)}`);
92
+ }
93
+ out.push(`${c.cyan(symbols.boxBL)}${c.cyan(symbols.boxH.repeat(inner))}${c.cyan(symbols.boxBR)}`);
94
+ return out.join("\n");
95
+ }
96
+
97
+ /** Baris label + value. */
98
+ export function row(label: string, value: string, labelWidth = 12): string {
99
+ return `${c.dim(label.padEnd(labelWidth))}${value}`;
100
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Gerbang kunci aplikasi. Selama kunci aktif dan sesi belum dibuka, app TIDAK
3
+ * dirender sama sekali — tidak ada request API / WebSocket yang berjalan dan
4
+ * tidak ada data yang tersisa di DOM. Yang tampil hanya `LockScreen`.
5
+ *
6
+ * Animasi (Motion `AnimatePresence`, `initial={false}` -> tidak beranimasi
7
+ * saat app pertama dibuka dalam keadaan terkunci):
8
+ * - Dikunci: lock screen turun dari atas. App lama ditahan di DOM (inert)
9
+ * hanya sampai lock screen mendarat, lalu dilepas.
10
+ * - Dibuka: lock screen naik keluar layar, app sudah ada di bawahnya.
11
+ * - Instalasi baru (`needsOnboarding`): welcome screen (`WelcomeScreen`)
12
+ * memudar masuk di lapisan sendiri; setelah selesai memudar keluar dan
13
+ * app muncul dari baliknya.
14
+ *
15
+ * Kunci otomatis: server menegakkan batas idle; klien menirunya dengan timer
16
+ * aktivitas lokal (agar layar terkunci tepat waktu walau tidak ada request)
17
+ * dan heartbeat status berkala (mendeteksi kunci dari perangkat lain /
18
+ * sesi dicabut).
19
+ */
20
+ import { AnimatePresence, useIsPresent, useReducedMotion } from "motion/react";
21
+ import * as m from "motion/react-m";
22
+ import { type ReactNode, useEffect, useRef, useState } from "react";
23
+ import { LockScreen } from "@/components/auth/LockScreen";
24
+ import { WelcomeScreen } from "@/components/auth/WelcomeScreen";
25
+ import { Button } from "@/components/ui/button";
26
+ import { Spinner } from "@/components/ui/spinner";
27
+ import { lockNow, refreshAuth, useAuth } from "@/lib/auth";
28
+
29
+ /** Interval cek status ke server saat app terbuka. */
30
+ const HEARTBEAT_MS = 60_000;
31
+
32
+ const ACTIVITY_EVENTS = ["pointerdown", "keydown", "wheel", "touchstart"] as const;
33
+
34
+ export function AuthGate({ children }: { children: ReactNode }) {
35
+ const auth = useAuth();
36
+
37
+ useEffect(() => {
38
+ void refreshAuth();
39
+ }, []);
40
+
41
+ const status = auth.phase === "ready" ? auth.status : null;
42
+ const unlocked = status !== null && (!status.protected || status.authenticated);
43
+
44
+ /**
45
+ * Welcome screen di-"latch": begitu tampil, ia bertahan sampai pengguna
46
+ * menyelesaikannya (`onFinished`) — walau di tengah alur server sudah
47
+ * melaporkan `needsOnboarding: false` (mis. setelah kunci dipasang di
48
+ * langkah Lock, heartbeat / refresh status lain tidak boleh memotong
49
+ * layar "Done").
50
+ */
51
+ const [onboarding, setOnboarding] = useState(false);
52
+ // Latch disetel SAAT render (bukan di effect) agar tidak ada satu frame
53
+ // pun app tampil sebelum welcome screen.
54
+ if (status?.needsOnboarding && !onboarding) setOnboarding(true);
55
+ const autoLockMs =
56
+ status?.protected && status.autoLockMinutes > 0 ? status.autoLockMinutes * 60_000 : 0;
57
+
58
+ // Kunci otomatis sisi klien + heartbeat.
59
+ const lastActivity = useRef(Date.now());
60
+ useEffect(() => {
61
+ if (!unlocked || !status?.protected) return;
62
+ lastActivity.current = Date.now();
63
+ const onActivity = () => {
64
+ lastActivity.current = Date.now();
65
+ };
66
+ for (const ev of ACTIVITY_EVENTS) window.addEventListener(ev, onActivity, { passive: true });
67
+
68
+ const tick = setInterval(() => {
69
+ if (autoLockMs > 0 && Date.now() - lastActivity.current >= autoLockMs) {
70
+ void lockNow();
71
+ }
72
+ }, 5_000);
73
+ const heartbeat = setInterval(() => void refreshAuth(), HEARTBEAT_MS);
74
+ // Kembali ke tab / buka app lagi di HP -> cek segera (timer bisa tertunda
75
+ // saat tab di latar belakang).
76
+ const onVisible = () => {
77
+ if (document.visibilityState !== "visible") return;
78
+ if (autoLockMs > 0 && Date.now() - lastActivity.current >= autoLockMs) void lockNow();
79
+ else void refreshAuth();
80
+ };
81
+ document.addEventListener("visibilitychange", onVisible);
82
+
83
+ return () => {
84
+ for (const ev of ACTIVITY_EVENTS) window.removeEventListener(ev, onActivity);
85
+ clearInterval(tick);
86
+ clearInterval(heartbeat);
87
+ document.removeEventListener("visibilitychange", onVisible);
88
+ };
89
+ }, [unlocked, status?.protected, autoLockMs]);
90
+
91
+ if (auth.phase === "loading") {
92
+ return (
93
+ <div className="flex h-dvh items-center justify-center bg-background" role="status">
94
+ <Spinner className="size-6 text-muted-foreground" />
95
+ <span className="sr-only">Loading…</span>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ if (auth.phase === "error") {
101
+ return (
102
+ <div className="flex h-dvh flex-col items-center justify-center gap-4 bg-background px-6 text-center">
103
+ <p className="text-sm text-muted-foreground">Can't reach the KCG Code server.</p>
104
+ <Button type="button" variant="outline" onClick={() => void refreshAuth()}>
105
+ Try again
106
+ </Button>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ const locked = !unlocked && status !== null;
112
+ /**
113
+ * Instalasi baru: welcome screen MENUTUPI app (lapisan sendiri di atas).
114
+ * App baru dirender setelah onboarding selesai — tidak ada request data
115
+ * selama welcome screen — lalu muncul dari balik layar yang memudar.
116
+ */
117
+ const showWelcome = !locked && (onboarding || status?.needsOnboarding === true);
118
+ return (
119
+ <>
120
+ <AnimatePresence initial={false}>
121
+ {locked ? (
122
+ <m.div
123
+ key="lock"
124
+ // Tirai: turun dari atas saat dikunci, naik keluar saat dibuka.
125
+ // Bayangan di tepi bawah hanya terlihat selama bergerak.
126
+ className="fixed inset-0 z-[100] shadow-[0_24px_48px_-12px_rgb(0_0_0/0.45)]"
127
+ initial={{ y: "-100%" }}
128
+ animate={{ y: 0, transition: { duration: 0.55, ease: LOCK_EASE } }}
129
+ exit={{ y: "-100%", transition: { duration: 0.45, ease: LOCK_EASE } }}
130
+ >
131
+ <LockScreen status={status} />
132
+ </m.div>
133
+ ) : showWelcome ? null : (
134
+ <AppLayer key="app">{children}</AppLayer>
135
+ )}
136
+ </AnimatePresence>
137
+
138
+ {/* Lapisan terpisah: SELALU beranimasi masuk (bahkan saat app pertama
139
+ dibuka) dan keluar dengan memudar + sedikit membesar. */}
140
+ <AnimatePresence>
141
+ {showWelcome && status && (
142
+ <m.div
143
+ key="welcome"
144
+ className="fixed inset-0 z-[90]"
145
+ initial={{ opacity: 0 }}
146
+ animate={{ opacity: 1, transition: { duration: 0.4, ease: "easeOut" } }}
147
+ exit={{ opacity: 0, scale: 1.03, transition: { duration: 0.45, ease: LOCK_EASE } }}
148
+ >
149
+ <WelcomeScreen status={status} onFinished={() => setOnboarding(false)} />
150
+ </m.div>
151
+ )}
152
+ </AnimatePresence>
153
+ </>
154
+ );
155
+ }
156
+
157
+ /** Kurva ala sheet iOS: cepat di awal, mendarat halus. */
158
+ const LOCK_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
159
+ /** Selama app lama ditahan di bawah lock screen yang sedang turun (detik). */
160
+ const LOCK_COVER_S = 0.55;
161
+
162
+ /**
163
+ * App yang sedang terbuka. Saat dikunci, `AnimatePresence` menahannya di DOM
164
+ * sampai lock screen selesai menutupi layar (`exit` tertunda
165
+ * `LOCK_COVER_S`), lalu melepasnya — tidak ada data yang tersisa. Selama
166
+ * ditahan: `inert` (tak bisa diklik, difokus, atau dibaca pembaca layar).
167
+ * `display: contents` agar pembungkus tidak mengubah layout shell.
168
+ */
169
+ function AppLayer({ children }: { children: ReactNode }) {
170
+ const present = useIsPresent();
171
+ // Reduced motion: lock screen tampil seketika, jadi app tak perlu ditahan.
172
+ const reduced = useReducedMotion();
173
+ return (
174
+ <m.div
175
+ className="contents"
176
+ inert={!present}
177
+ exit={{ opacity: 0, transition: { delay: reduced ? 0 : LOCK_COVER_S, duration: 0 } }}
178
+ >
179
+ {children}
180
+ </m.div>
181
+ );
182
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Avatar yang bisa diedit (Settings -> Profile): avatar besar dengan tombol
3
+ * pensil di pojok. Pensil membuka popover berisi:
4
+ * - grid avatar bawaan (ikon) — klik untuk langsung dipakai,
5
+ * - "Upload photo" untuk foto kustom (PNG/JPEG/WebP/GIF, maks 2 MB),
6
+ * - "Remove" untuk kembali ke inisial.
7
+ *
8
+ * Aksesibilitas: grid = daftar tombol toggle (`aria-pressed` = pilihan saat
9
+ * ini). Panah kiri/kanan/atas/bawah memindah fokus tanpa memilih — memilih
10
+ * langsung menyimpan, jadi harus disengaja (Enter/Space/klik).
11
+ */
12
+ import { CheckIcon, ImageUpIcon, PencilIcon, Trash2Icon } from "lucide-react";
13
+ import { useRef, useState } from "react";
14
+ import { AVATAR_PRESETS } from "@/components/auth/avatar-icons";
15
+ import { ProfileAvatar } from "@/components/auth/ProfileAvatar";
16
+ import { Button } from "@/components/ui/button";
17
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
18
+ import { Spinner } from "@/components/ui/spinner";
19
+ import { ApiError, apiFetch } from "@/lib/api";
20
+ import { refreshAuth } from "@/lib/auth";
21
+ import { AVATAR_PRESET_IDS, type AvatarPresetId } from "@/lib/avatar-presets";
22
+ import { cn } from "@/lib/utils";
23
+ import type { AuthProfile } from "@/server/services/auth";
24
+
25
+ const COLUMNS = 4;
26
+
27
+ export interface AvatarEditorProps {
28
+ profile: AuthProfile;
29
+ /** Pesan hasil (sukses / error) untuk ditampilkan pemanggil. */
30
+ onResult: (result: { ok: string } | { error: string }) => void;
31
+ }
32
+
33
+ export function AvatarEditor({ profile, onResult }: AvatarEditorProps) {
34
+ const [open, setOpen] = useState(false);
35
+ const [busy, setBusy] = useState<AvatarPresetId | "upload" | "remove" | null>(null);
36
+ const fileRef = useRef<HTMLInputElement>(null);
37
+ const gridRef = useRef<HTMLFieldSetElement>(null);
38
+ const hasAvatar = profile.avatarUrl !== null || profile.avatarPreset !== null;
39
+
40
+ const run = async (
41
+ kind: AvatarPresetId | "upload" | "remove",
42
+ action: () => Promise<unknown>,
43
+ ok: string,
44
+ ) => {
45
+ setBusy(kind);
46
+ try {
47
+ await action();
48
+ await refreshAuth();
49
+ onResult({ ok });
50
+ setOpen(false);
51
+ } catch (e) {
52
+ onResult({ error: e instanceof ApiError ? e.message : "Couldn't update the avatar." });
53
+ } finally {
54
+ setBusy(null);
55
+ }
56
+ };
57
+
58
+ const pickPreset = (id: AvatarPresetId) =>
59
+ run(
60
+ id,
61
+ () =>
62
+ apiFetch("/api/auth/avatar/preset", {
63
+ method: "PUT",
64
+ body: JSON.stringify({ preset: id }),
65
+ }),
66
+ "Avatar updated.",
67
+ );
68
+
69
+ const upload = (file: File) => {
70
+ const body = new FormData();
71
+ body.append("file", file);
72
+ return run(
73
+ "upload",
74
+ () => apiFetch("/api/auth/avatar", { method: "PUT", body }),
75
+ "Photo updated.",
76
+ );
77
+ };
78
+
79
+ const remove = () =>
80
+ run("remove", () => apiFetch("/api/auth/avatar", { method: "DELETE" }), "Avatar removed.");
81
+
82
+ // Navigasi panah di grid (roving tabindex).
83
+ const onGridKey = (e: React.KeyboardEvent<HTMLFieldSetElement>) => {
84
+ const items = [
85
+ ...(gridRef.current?.querySelectorAll<HTMLButtonElement>("[data-avatar-option]") ?? []),
86
+ ];
87
+ const idx = items.indexOf(document.activeElement as HTMLButtonElement);
88
+ if (idx === -1) return;
89
+ const step: Record<string, number> = {
90
+ ArrowRight: 1,
91
+ ArrowLeft: -1,
92
+ ArrowDown: COLUMNS,
93
+ ArrowUp: -COLUMNS,
94
+ };
95
+ const d = step[e.key];
96
+ if (d === undefined) return;
97
+ e.preventDefault();
98
+ items[(idx + d + items.length) % items.length]?.focus();
99
+ };
100
+
101
+ const selectedIdx = profile.avatarPreset ? AVATAR_PRESET_IDS.indexOf(profile.avatarPreset) : -1;
102
+
103
+ return (
104
+ <div className="relative w-fit">
105
+ <ProfileAvatar
106
+ nickname={profile.nickname}
107
+ avatarUrl={profile.avatarUrl}
108
+ avatarPreset={profile.avatarPreset}
109
+ className="size-28 text-5xl"
110
+ textClassName="text-4xl"
111
+ />
112
+ {busy === "upload" && (
113
+ <span className="absolute inset-0 flex items-center justify-center rounded-full bg-background/70">
114
+ <Spinner className="size-6" />
115
+ </span>
116
+ )}
117
+
118
+ <Popover open={open} onOpenChange={setOpen}>
119
+ <PopoverTrigger asChild>
120
+ <button
121
+ type="button"
122
+ aria-label="Edit avatar"
123
+ className="absolute right-0 bottom-0 flex size-9 items-center justify-center rounded-full border-2 border-background bg-primary text-primary-foreground shadow-md transition-transform outline-none hover:scale-105 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:scale-105"
124
+ >
125
+ <PencilIcon className="size-4" />
126
+ </button>
127
+ </PopoverTrigger>
128
+ <PopoverContent
129
+ align="center"
130
+ side="bottom"
131
+ sideOffset={10}
132
+ className="w-[19rem] rounded-xl p-3"
133
+ onOpenAutoFocus={(e) => {
134
+ // Fokus ke preset terpilih (atau pertama) agar panah langsung jalan.
135
+ e.preventDefault();
136
+ const items =
137
+ gridRef.current?.querySelectorAll<HTMLButtonElement>("[data-avatar-option]");
138
+ items?.[Math.max(0, selectedIdx)]?.focus();
139
+ }}
140
+ >
141
+ <p className="px-1 pb-2 text-sm font-semibold">Choose an avatar</p>
142
+ <fieldset
143
+ ref={gridRef}
144
+ aria-label="Default avatars"
145
+ onKeyDown={onGridKey}
146
+ className="grid grid-cols-4 gap-2 border-0 p-0"
147
+ >
148
+ {AVATAR_PRESET_IDS.map((id, i) => {
149
+ const p = AVATAR_PRESETS[id];
150
+ const selected = profile.avatarPreset === id;
151
+ return (
152
+ <button
153
+ key={id}
154
+ type="button"
155
+ data-avatar-option
156
+ aria-pressed={selected}
157
+ aria-label={p.label}
158
+ title={p.label}
159
+ tabIndex={i === Math.max(0, selectedIdx) ? 0 : -1}
160
+ disabled={busy !== null}
161
+ onClick={() => void pickPreset(id)}
162
+ className={cn(
163
+ "relative flex aspect-square items-center justify-center rounded-full transition-transform outline-none hover:scale-105 focus-visible:ring-[3px] focus-visible:ring-ring/60 disabled:opacity-60",
164
+ p.className,
165
+ selected && "ring-2 ring-primary ring-offset-2 ring-offset-popover",
166
+ )}
167
+ >
168
+ {busy === id ? (
169
+ <Spinner className="size-5" />
170
+ ) : (
171
+ <p.Icon className="size-[55%]" strokeWidth={1.75} aria-hidden />
172
+ )}
173
+ {selected && busy !== id && (
174
+ <span className="absolute -right-0.5 -bottom-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground">
175
+ <CheckIcon className="size-3" strokeWidth={3} aria-hidden />
176
+ </span>
177
+ )}
178
+ </button>
179
+ );
180
+ })}
181
+ </fieldset>
182
+
183
+ <div className="mt-3 flex flex-col gap-1 border-t pt-3">
184
+ <Button
185
+ type="button"
186
+ variant="ghost"
187
+ className="w-full justify-start"
188
+ disabled={busy !== null}
189
+ onClick={() => fileRef.current?.click()}
190
+ >
191
+ {busy === "upload" ? (
192
+ <Spinner data-icon="inline-start" />
193
+ ) : (
194
+ <ImageUpIcon data-icon="inline-start" />
195
+ )}
196
+ Upload photo…
197
+ </Button>
198
+ {hasAvatar && (
199
+ <Button
200
+ type="button"
201
+ variant="ghost"
202
+ className="w-full justify-start text-destructive hover:text-destructive"
203
+ disabled={busy !== null}
204
+ onClick={() => void remove()}
205
+ >
206
+ {busy === "remove" ? (
207
+ <Spinner data-icon="inline-start" />
208
+ ) : (
209
+ <Trash2Icon data-icon="inline-start" />
210
+ )}
211
+ Remove avatar
212
+ </Button>
213
+ )}
214
+ <p className="px-3 pt-1 text-[11px] text-muted-foreground">
215
+ PNG, JPEG, WebP or GIF, up to 2 MB.
216
+ </p>
217
+ </div>
218
+ </PopoverContent>
219
+ </Popover>
220
+
221
+ <input
222
+ ref={fileRef}
223
+ type="file"
224
+ accept="image/png,image/jpeg,image/webp,image/gif"
225
+ className="sr-only"
226
+ tabIndex={-1}
227
+ aria-hidden
228
+ onChange={(e) => {
229
+ const f = e.target.files?.[0];
230
+ e.target.value = "";
231
+ if (f) void upload(f);
232
+ }}
233
+ />
234
+ </div>
235
+ );
236
+ }