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.
- package/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
package/src/cli/theme.ts
ADDED
|
@@ -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
|
+
}
|