@surething/cockpit 1.0.259 → 1.0.261

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 (249) hide show
  1. package/.next-prod/BUILD_ID +1 -1
  2. package/.next-prod/app-path-routes-manifest.json +1 -1
  3. package/.next-prod/build-manifest.json +2 -2
  4. package/.next-prod/cockpit-build.json +1 -1
  5. package/.next-prod/prerender-manifest.json +4 -4
  6. package/.next-prod/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next-prod/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next-prod/server/app/_global-error.html +1 -1
  9. package/.next-prod/server/app/_global-error.rsc +1 -1
  10. package/.next-prod/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next-prod/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next-prod/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next-prod/server/app/_not-found/page.js +1 -1
  14. package/.next-prod/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next-prod/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next-prod/server/app/_not-found.html +2 -2
  17. package/.next-prod/server/app/_not-found.rsc +3 -3
  18. package/.next-prod/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next-prod/server/app/_not-found.segments/_index.segment.rsc +3 -3
  20. package/.next-prod/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/.next-prod/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next-prod/server/app/api/agent/test/route.js.nft.json +1 -1
  23. package/.next-prod/server/app/api/bash/route.js.nft.json +1 -1
  24. package/.next-prod/server/app/api/chat/codex/route.js +1 -1
  25. package/.next-prod/server/app/api/chat/codex/route.js.nft.json +1 -1
  26. package/.next-prod/server/app/api/chat/deepseek/route.js +1 -1
  27. package/.next-prod/server/app/api/chat/deepseek/route.js.nft.json +1 -1
  28. package/.next-prod/server/app/api/chat/glm/route.js +1 -1
  29. package/.next-prod/server/app/api/chat/glm/route.js.nft.json +1 -1
  30. package/.next-prod/server/app/api/chat/kimi/route.js +1 -1
  31. package/.next-prod/server/app/api/chat/kimi/route.js.nft.json +1 -1
  32. package/.next-prod/server/app/api/chat/ollama/route.js.nft.json +1 -1
  33. package/.next-prod/server/app/api/chat/route.js +1 -1
  34. package/.next-prod/server/app/api/chat/route.js.nft.json +1 -1
  35. package/.next-prod/server/app/api/chat/stop/route.js +1 -1
  36. package/.next-prod/server/app/api/chat/stop/route.js.nft.json +1 -1
  37. package/.next-prod/server/app/api/claude-stats/route.js.nft.json +1 -1
  38. package/.next-prod/server/app/api/commands/route.js.nft.json +1 -1
  39. package/.next-prod/server/app/api/comments/route.js.nft.json +1 -1
  40. package/.next-prod/server/app/api/db/columns/route.js.nft.json +1 -1
  41. package/.next-prod/server/app/api/db/connect/route.js.nft.json +1 -1
  42. package/.next-prod/server/app/api/db/disconnect/route.js.nft.json +1 -1
  43. package/.next-prod/server/app/api/db/export/route.js.nft.json +1 -1
  44. package/.next-prod/server/app/api/db/query/route.js.nft.json +1 -1
  45. package/.next-prod/server/app/api/db/schemas/route.js.nft.json +1 -1
  46. package/.next-prod/server/app/api/deepseek/balance/route.js.nft.json +1 -1
  47. package/.next-prod/server/app/api/deepseek/credentials/route.js.nft.json +1 -1
  48. package/.next-prod/server/app/api/deepseek/models/route.js.nft.json +1 -1
  49. package/.next-prod/server/app/api/dev/spans/route.js.nft.json +1 -1
  50. package/.next-prod/server/app/api/extension/version/route.js.nft.json +1 -1
  51. package/.next-prod/server/app/api/file/route.js.nft.json +1 -1
  52. package/.next-prod/server/app/api/files/blame/route.js.nft.json +1 -1
  53. package/.next-prod/server/app/api/files/clipboard/route.js.nft.json +1 -1
  54. package/.next-prod/server/app/api/files/copy/route.js.nft.json +1 -1
  55. package/.next-prod/server/app/api/files/delete/route.js.nft.json +1 -1
  56. package/.next-prod/server/app/api/files/expanded/route.js.nft.json +1 -1
  57. package/.next-prod/server/app/api/files/index/route.js.nft.json +1 -1
  58. package/.next-prod/server/app/api/files/init/route.js.nft.json +1 -1
  59. package/.next-prod/server/app/api/files/paste/route.js.nft.json +1 -1
  60. package/.next-prod/server/app/api/files/read/route.js.nft.json +1 -1
  61. package/.next-prod/server/app/api/files/readdir/route.js.nft.json +1 -1
  62. package/.next-prod/server/app/api/files/recent/route.js.nft.json +1 -1
  63. package/.next-prod/server/app/api/files/save/route.js.nft.json +1 -1
  64. package/.next-prod/server/app/api/files/search/route.js.nft.json +1 -1
  65. package/.next-prod/server/app/api/files/stat/route.js.nft.json +1 -1
  66. package/.next-prod/server/app/api/files/text/route.js.nft.json +1 -1
  67. package/.next-prod/server/app/api/git/branch-diff/route.js.nft.json +1 -1
  68. package/.next-prod/server/app/api/git/branches/route.js.nft.json +1 -1
  69. package/.next-prod/server/app/api/git/commit-diff/route.js.nft.json +1 -1
  70. package/.next-prod/server/app/api/git/commits/route.js.nft.json +1 -1
  71. package/.next-prod/server/app/api/git/current-branch/route.js.nft.json +1 -1
  72. package/.next-prod/server/app/api/git/diff/route.js.nft.json +1 -1
  73. package/.next-prod/server/app/api/git/discard/route.js.nft.json +1 -1
  74. package/.next-prod/server/app/api/git/stage/route.js.nft.json +1 -1
  75. package/.next-prod/server/app/api/git/status/route.js.nft.json +1 -1
  76. package/.next-prod/server/app/api/git/unstage/route.js.nft.json +1 -1
  77. package/.next-prod/server/app/api/git/worktree/route.js.nft.json +1 -1
  78. package/.next-prod/server/app/api/glm/credentials/route.js.nft.json +1 -1
  79. package/.next-prod/server/app/api/glm/models/route.js +1 -1
  80. package/.next-prod/server/app/api/glm/models/route.js.nft.json +1 -1
  81. package/.next-prod/server/app/api/glm/usage/route.js +1 -1
  82. package/.next-prod/server/app/api/glm/usage/route.js.nft.json +1 -1
  83. package/.next-prod/server/app/api/global-state/route.js.nft.json +1 -1
  84. package/.next-prod/server/app/api/health/route.js.nft.json +1 -1
  85. package/.next-prod/server/app/api/html-apps/[id]/route.js +1 -1
  86. package/.next-prod/server/app/api/html-apps/[id]/route.js.nft.json +1 -1
  87. package/.next-prod/server/app/api/html-apps/route.js +1 -1
  88. package/.next-prod/server/app/api/html-apps/route.js.nft.json +1 -1
  89. package/.next-prod/server/app/api/jupyter/load/route.js.nft.json +1 -1
  90. package/.next-prod/server/app/api/jupyter/save/route.js.nft.json +1 -1
  91. package/.next-prod/server/app/api/jupyter/shutdown/route.js.nft.json +1 -1
  92. package/.next-prod/server/app/api/kimi/credentials/route.js.nft.json +1 -1
  93. package/.next-prod/server/app/api/kimi/models/route.js +1 -1
  94. package/.next-prod/server/app/api/kimi/models/route.js.nft.json +1 -1
  95. package/.next-prod/server/app/api/kimi/usage/route.js +1 -1
  96. package/.next-prod/server/app/api/kimi/usage/route.js.nft.json +1 -1
  97. package/.next-prod/server/app/api/lsp/definition/route.js.nft.json +1 -1
  98. package/.next-prod/server/app/api/lsp/hover/route.js.nft.json +1 -1
  99. package/.next-prod/server/app/api/lsp/references/route.js.nft.json +1 -1
  100. package/.next-prod/server/app/api/lsp/status/route.js.nft.json +1 -1
  101. package/.next-prod/server/app/api/lsp/warmup/route.js.nft.json +1 -1
  102. package/.next-prod/server/app/api/mysql/columns/route.js.nft.json +1 -1
  103. package/.next-prod/server/app/api/mysql/connect/route.js.nft.json +1 -1
  104. package/.next-prod/server/app/api/mysql/disconnect/route.js.nft.json +1 -1
  105. package/.next-prod/server/app/api/mysql/export/route.js.nft.json +1 -1
  106. package/.next-prod/server/app/api/mysql/query/route.js.nft.json +1 -1
  107. package/.next-prod/server/app/api/mysql/schemas/route.js.nft.json +1 -1
  108. package/.next-prod/server/app/api/neo4j/connect/route.js.nft.json +1 -1
  109. package/.next-prod/server/app/api/neo4j/disconnect/route.js.nft.json +1 -1
  110. package/.next-prod/server/app/api/neo4j/query/route.js.nft.json +1 -1
  111. package/.next-prod/server/app/api/neo4j/schema/route.js.nft.json +1 -1
  112. package/.next-prod/server/app/api/note/route.js.nft.json +1 -1
  113. package/.next-prod/server/app/api/ollama/config/route.js.nft.json +1 -1
  114. package/.next-prod/server/app/api/ollama/models/route.js.nft.json +1 -1
  115. package/.next-prod/server/app/api/ollama/start/route.js.nft.json +1 -1
  116. package/.next-prod/server/app/api/open-cursor/route.js.nft.json +1 -1
  117. package/.next-prod/server/app/api/open-vscode/route.js.nft.json +1 -1
  118. package/.next-prod/server/app/api/pick-folder/route.js.nft.json +1 -1
  119. package/.next-prod/server/app/api/pinned-sessions/route.js.nft.json +1 -1
  120. package/.next-prod/server/app/api/project-settings/route.js.nft.json +1 -1
  121. package/.next-prod/server/app/api/project-state/route.js +1 -1
  122. package/.next-prod/server/app/api/project-state/route.js.nft.json +1 -1
  123. package/.next-prod/server/app/api/projectGraph/affected/route.js.nft.json +1 -1
  124. package/.next-prod/server/app/api/projectGraph/callees/route.js.nft.json +1 -1
  125. package/.next-prod/server/app/api/projectGraph/callers/route.js.nft.json +1 -1
  126. package/.next-prod/server/app/api/projectGraph/coedit/route.js.nft.json +1 -1
  127. package/.next-prod/server/app/api/projectGraph/context/route.js.nft.json +1 -1
  128. package/.next-prod/server/app/api/projectGraph/file/route.js.nft.json +1 -1
  129. package/.next-prod/server/app/api/projectGraph/file-functions/route.js.nft.json +1 -1
  130. package/.next-prod/server/app/api/projectGraph/impact/route.js.nft.json +1 -1
  131. package/.next-prod/server/app/api/projectGraph/related/route.js.nft.json +1 -1
  132. package/.next-prod/server/app/api/projectGraph/risk/route.js.nft.json +1 -1
  133. package/.next-prod/server/app/api/projectGraph/search/route.js.nft.json +1 -1
  134. package/.next-prod/server/app/api/projects/route.js.nft.json +1 -1
  135. package/.next-prod/server/app/api/prompts/config/route.js.nft.json +1 -1
  136. package/.next-prod/server/app/api/push/public-key/route.js.nft.json +1 -1
  137. package/.next-prod/server/app/api/push/subscribe/route.js.nft.json +1 -1
  138. package/.next-prod/server/app/api/push/unsubscribe/route.js.nft.json +1 -1
  139. package/.next-prod/server/app/api/redis/command/route.js.nft.json +1 -1
  140. package/.next-prod/server/app/api/redis/connect/route.js.nft.json +1 -1
  141. package/.next-prod/server/app/api/redis/delete/route.js.nft.json +1 -1
  142. package/.next-prod/server/app/api/redis/disconnect/route.js.nft.json +1 -1
  143. package/.next-prod/server/app/api/redis/get/route.js.nft.json +1 -1
  144. package/.next-prod/server/app/api/redis/keys/route.js.nft.json +1 -1
  145. package/.next-prod/server/app/api/redis/set/route.js.nft.json +1 -1
  146. package/.next-prod/server/app/api/review/[id]/comments/route.js.nft.json +1 -1
  147. package/.next-prod/server/app/api/review/[id]/replies/route.js.nft.json +1 -1
  148. package/.next-prod/server/app/api/review/[id]/route.js.nft.json +1 -1
  149. package/.next-prod/server/app/api/review/identify/route.js.nft.json +1 -1
  150. package/.next-prod/server/app/api/review/order/route.js.nft.json +1 -1
  151. package/.next-prod/server/app/api/review/route.js.nft.json +1 -1
  152. package/.next-prod/server/app/api/review/share-info/route.js.nft.json +1 -1
  153. package/.next-prod/server/app/api/review/users/route.js.nft.json +1 -1
  154. package/.next-prod/server/app/api/scheduled-tasks/route.js +2 -2
  155. package/.next-prod/server/app/api/scheduled-tasks/route.js.nft.json +1 -1
  156. package/.next-prod/server/app/api/services/config/route.js.nft.json +1 -1
  157. package/.next-prod/server/app/api/services/scripts/route.js.nft.json +1 -1
  158. package/.next-prod/server/app/api/session/[sessionId]/fork/route.js +1 -1
  159. package/.next-prod/server/app/api/session/[sessionId]/fork/route.js.nft.json +1 -1
  160. package/.next-prod/server/app/api/session/[sessionId]/history/route.js.nft.json +1 -1
  161. package/.next-prod/server/app/api/session-by-path/route.js +1 -1
  162. package/.next-prod/server/app/api/session-by-path/route.js.nft.json +1 -1
  163. package/.next-prod/server/app/api/sessions/projects/[encodedPath]/route.js +1 -1
  164. package/.next-prod/server/app/api/sessions/projects/[encodedPath]/route.js.nft.json +1 -1
  165. package/.next-prod/server/app/api/sessions/projects/route.js +1 -1
  166. package/.next-prod/server/app/api/sessions/projects/route.js.nft.json +1 -1
  167. package/.next-prod/server/app/api/sessions/route.js.nft.json +1 -1
  168. package/.next-prod/server/app/api/settings/route.js.nft.json +1 -1
  169. package/.next-prod/server/app/api/skills/[id]/route.js.nft.json +1 -1
  170. package/.next-prod/server/app/api/skills/content/route.js.nft.json +1 -1
  171. package/.next-prod/server/app/api/skills/route.js.nft.json +1 -1
  172. package/.next-prod/server/app/api/snapshots/diff/route.js.nft.json +1 -1
  173. package/.next-prod/server/app/api/snapshots/route.js.nft.json +1 -1
  174. package/.next-prod/server/app/api/terminal/aliases/route.js.nft.json +1 -1
  175. package/.next-prod/server/app/api/terminal/autocomplete/route.js.nft.json +1 -1
  176. package/.next-prod/server/app/api/terminal/bubble-order/route.js.nft.json +1 -1
  177. package/.next-prod/server/app/api/terminal/env/route.js.nft.json +1 -1
  178. package/.next-prod/server/app/api/terminal/history/route.js.nft.json +1 -1
  179. package/.next-prod/server/app/api/version/route.js.nft.json +1 -1
  180. package/.next-prod/server/app/apps/[...path]/route.js +1 -1
  181. package/.next-prod/server/app/apps/[...path]/route.js.nft.json +1 -1
  182. package/.next-prod/server/app/favicon.ico/route.js.nft.json +1 -1
  183. package/.next-prod/server/app/m/page.js +1 -1
  184. package/.next-prod/server/app/m/page.js.nft.json +1 -1
  185. package/.next-prod/server/app/m/page_client-reference-manifest.js +1 -1
  186. package/.next-prod/server/app/manifest.webmanifest/route.js.nft.json +1 -1
  187. package/.next-prod/server/app/page.js +1 -1
  188. package/.next-prod/server/app/page.js.nft.json +1 -1
  189. package/.next-prod/server/app/page_client-reference-manifest.js +1 -1
  190. package/.next-prod/server/app/project/page.js +1 -1
  191. package/.next-prod/server/app/project/page.js.nft.json +1 -1
  192. package/.next-prod/server/app/project/page_client-reference-manifest.js +1 -1
  193. package/.next-prod/server/app/review/[id]/page.js +1 -1
  194. package/.next-prod/server/app/review/[id]/page.js.nft.json +1 -1
  195. package/.next-prod/server/app/review/[id]/page_client-reference-manifest.js +1 -1
  196. package/.next-prod/server/app-paths-manifest.json +1 -1
  197. package/.next-prod/server/chunks/3286.js +406 -270
  198. package/.next-prod/server/chunks/{9004.js → 3641.js} +2 -2
  199. package/.next-prod/server/chunks/516.js +1 -0
  200. package/.next-prod/server/chunks/5750.js +16 -10
  201. package/.next-prod/server/chunks/6495.js +1 -1
  202. package/.next-prod/server/chunks/6548.js +1 -1
  203. package/.next-prod/server/chunks/816.js +2 -0
  204. package/.next-prod/server/chunks/841.js +12 -10
  205. package/.next-prod/server/chunks/9352.js +4 -4
  206. package/.next-prod/server/chunks/9830.js +1 -1
  207. package/.next-prod/server/middleware-build-manifest.js +1 -1
  208. package/.next-prod/server/pages/404.html +2 -2
  209. package/.next-prod/server/pages/500.html +1 -1
  210. package/.next-prod/server/server-reference-manifest.json +1 -1
  211. package/.next-prod/static/chunks/1157-164bcf046a0a9b15.js +32 -0
  212. package/.next-prod/static/chunks/1372-d16d68b1b03a345a.js +15 -0
  213. package/.next-prod/static/chunks/{2653-3f8bfa49727caa72.js → 4333-9e6709e96d3ec973.js} +7 -7
  214. package/.next-prod/static/chunks/{225-75b2e22f496b56a1.js → 6160-958532439d324185.js} +2 -2
  215. package/.next-prod/static/chunks/6784-09dffdb0e8ca7fb3.js +381 -0
  216. package/.next-prod/static/chunks/app/{layout-fef0d44c94fcb415.js → layout-ab52fc1a83af968b.js} +1 -1
  217. package/.next-prod/static/chunks/app/m/page-7dcf56a65528216f.js +1 -0
  218. package/.next-prod/static/chunks/app/{page-824be00a023f34d7.js → page-5de8b6584e328a6f.js} +1 -1
  219. package/.next-prod/static/chunks/app/project/{page-824be00a023f34d7.js → page-5de8b6584e328a6f.js} +1 -1
  220. package/.next-prod/static/css/83c134c954630942.css +1 -0
  221. package/.next-prod/trace +14 -14
  222. package/.next-prod/trace-build +1 -1
  223. package/README.md +1 -1
  224. package/README.zh.md +1 -1
  225. package/apps/devkit/app.jsx +730 -0
  226. package/apps/devkit/index.html +178 -0
  227. package/apps/devkit/md5.js +98 -0
  228. package/dist/{chunk-L5TWVHYB.mjs → chunk-B5LXMESP.mjs} +19 -9
  229. package/dist/{chunk-YEZ3U3F2.mjs → chunk-CLPJYIFC.mjs} +1 -1
  230. package/dist/{chunk-RSRPFWNL.mjs → chunk-TU3GL2HK.mjs} +1 -27
  231. package/dist/httpApi.mjs +2 -2
  232. package/dist/scheduledTasks.mjs +685 -462
  233. package/dist/{server-UZPDELUK.mjs → server-2UJDVZ32.mjs} +2 -2
  234. package/dist/wsServer.mjs +5 -4
  235. package/package.json +1 -1
  236. package/public/agent-icons/claude.svg +1 -0
  237. package/public/agent-icons/codex.svg +1 -0
  238. package/public/agent-icons/deepseek.svg +1 -0
  239. package/public/agent-icons/glm.svg +1 -0
  240. package/public/agent-icons/kimi.svg +1 -0
  241. package/public/agent-icons/ollama.svg +1 -0
  242. package/.next-prod/server/chunks/151.js +0 -1
  243. package/.next-prod/static/chunks/1372-d94b8f9ed5cd2713.js +0 -15
  244. package/.next-prod/static/chunks/6784-ec12c61b77c64dc0.js +0 -381
  245. package/.next-prod/static/chunks/7482-3ffe7da02d3e7f45.js +0 -26
  246. package/.next-prod/static/chunks/app/m/page-aff83d3f5f4047c5.js +0 -1
  247. package/.next-prod/static/css/a82580b2376d7cae.css +0 -1
  248. /package/.next-prod/static/{F9dxCcbBY_bNPY4fxWaWe → pgteRwI6FQcp683smeSNO}/_buildManifest.js +0 -0
  249. /package/.next-prod/static/{F9dxCcbBY_bNPY4fxWaWe → pgteRwI6FQcp683smeSNO}/_ssgManifest.js +0 -0
@@ -0,0 +1,730 @@
1
+ /**
2
+ * DevKit -- seven high-frequency developer conversions, all pure computation.
3
+ *
4
+ * Deliberately does NOT use `cockpit.bash`: every tool here is input -> output
5
+ * with no filesystem, network or system dependency, so shelling out would only
6
+ * buy a ~50ms process spawn per keystroke and a "is that binary installed?"
7
+ * failure mode. MD5 comes from ./md5.js, SHA from the browser's crypto.subtle.
8
+ */
9
+ // React/ReactDOM come from the /html-lib script tags in index.html; `md5` is
10
+ // the global exported by the sibling ./md5.js script tag.
11
+ /* global React, ReactDOM, md5 */
12
+ const { useState, useEffect, useMemo, useCallback, useRef, useContext, createContext } = React;
13
+
14
+ /* ------------------------------------------------------------------ utils */
15
+
16
+ // crypto.subtle is a secure-context API. localhost (how Cockpit is normally
17
+ // reached) qualifies; a LAN IP does not, so the SHA rows degrade to a notice
18
+ // instead of throwing. getRandomValues has no such restriction, which is why
19
+ // UUIDs are built from it rather than from crypto.randomUUID.
20
+ const SUBTLE = typeof crypto !== 'undefined' && crypto.subtle ? crypto.subtle : null;
21
+
22
+ function encodeBase64(text) {
23
+ const bytes = new TextEncoder().encode(text);
24
+ let binary = '';
25
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
26
+ return btoa(binary);
27
+ }
28
+
29
+ function decodeBase64(text) {
30
+ const binary = atob(text.replace(/\s+/g, ''));
31
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
32
+ return new TextDecoder().decode(bytes);
33
+ }
34
+
35
+ function decodeBase64Url(segment) {
36
+ let s = segment.replace(/-/g, '+').replace(/_/g, '/');
37
+ while (s.length % 4) s += '=';
38
+ const binary = atob(s);
39
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
40
+ return new TextDecoder().decode(bytes);
41
+ }
42
+
43
+ /**
44
+ * Decode a compact JWS. Decoding only -- the signature is shown but never
45
+ * checked, because verifying needs the key (a shared secret for HS algorithms,
46
+ * a public key for RS and ES ones) which this tool has no way to obtain.
47
+ * The UI says so explicitly: a decoded token is not a trusted one.
48
+ */
49
+ function decodeJwt(raw) {
50
+ const token = raw.replace(/\s+/g, '');
51
+ if (!token) return null;
52
+
53
+ const parts = token.split('.');
54
+ if (parts.length === 5) {
55
+ return { error: 'This looks like a JWE (5 segments) — the payload is encrypted and cannot be decoded without the key.' };
56
+ }
57
+ if (parts.length !== 3) {
58
+ return { error: `A compact JWT has 3 dot-separated segments; this one has ${parts.length}.` };
59
+ }
60
+
61
+ const out = { signature: parts[2] };
62
+ try {
63
+ out.header = JSON.parse(decodeBase64Url(parts[0]));
64
+ } catch (e) {
65
+ return { error: `Header is not valid Base64URL-encoded JSON.\n\n${e.message}` };
66
+ }
67
+ try {
68
+ out.payload = JSON.parse(decodeBase64Url(parts[1]));
69
+ } catch (e) {
70
+ return { error: `Payload is not valid Base64URL-encoded JSON.\n\n${e.message}` };
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function relativeFromNow(ms) {
76
+ const diff = ms - Date.now();
77
+ const abs = Math.abs(diff);
78
+ const units = [[86400000, 'day'], [3600000, 'hour'], [60000, 'minute'], [1000, 'second']];
79
+ for (const [size, name] of units) {
80
+ if (abs >= size) {
81
+ const n = Math.floor(abs / size);
82
+ const plural = `${n} ${name}${n === 1 ? '' : 's'}`;
83
+ return diff < 0 ? `${plural} ago` : `in ${plural}`;
84
+ }
85
+ }
86
+ return 'just now';
87
+ }
88
+
89
+ function toHex(buffer) {
90
+ const bytes = new Uint8Array(buffer);
91
+ let out = '';
92
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0');
93
+ return out;
94
+ }
95
+
96
+ function uuidV4() {
97
+ const b = new Uint8Array(16);
98
+ crypto.getRandomValues(b);
99
+ b[6] = (b[6] & 0x0f) | 0x40; // version 4
100
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10x
101
+ let h = '';
102
+ for (let i = 0; i < 16; i++) h += b[i].toString(16).padStart(2, '0');
103
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
104
+ }
105
+
106
+ const pad2 = (n) => String(n).padStart(2, '0');
107
+
108
+ function formatLocal(d) {
109
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
110
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
111
+ }
112
+
113
+ function formatUTC(d) {
114
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ` +
115
+ `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())} UTC`;
116
+ }
117
+
118
+ // Digit count picks the unit, the way every timestamp tool does it.
119
+ function parseEpoch(raw) {
120
+ const s = raw.trim();
121
+ if (!s) return null;
122
+ if (!/^-?\d+$/.test(s)) return { error: 'Digits only — seconds, milliseconds, microseconds or nanoseconds.' };
123
+ const digits = s.replace('-', '').length;
124
+ let ms, unit;
125
+ if (digits <= 10) { ms = Number(s) * 1000; unit = 'seconds'; }
126
+ else if (digits <= 13) { ms = Number(s); unit = 'milliseconds'; }
127
+ else if (digits <= 16) { ms = Number(s) / 1000; unit = 'microseconds'; }
128
+ else { ms = Number(s) / 1e6; unit = 'nanoseconds'; }
129
+ const date = new Date(ms);
130
+ if (Number.isNaN(date.getTime())) return { error: 'Out of the representable date range.' };
131
+ return { date, unit };
132
+ }
133
+
134
+ function useDebounced(value, delay) {
135
+ const [settled, setSettled] = useState(value);
136
+ useEffect(() => {
137
+ const timer = setTimeout(() => setSettled(value), delay);
138
+ return () => clearTimeout(timer);
139
+ }, [value, delay]);
140
+ return settled;
141
+ }
142
+
143
+ /* ------------------------------------------------------- shared components */
144
+
145
+ const ToastContext = createContext(() => {});
146
+
147
+ function CopyButton({ text, label }) {
148
+ const toast = useContext(ToastContext);
149
+ const onClick = useCallback(async () => {
150
+ if (!text) return;
151
+ try {
152
+ // navigator.clipboard is also secure-context only; the textarea fallback
153
+ // keeps copy working when DevKit is opened over a LAN IP.
154
+ if (navigator.clipboard && window.isSecureContext) {
155
+ await navigator.clipboard.writeText(text);
156
+ } else {
157
+ const ta = document.createElement('textarea');
158
+ ta.value = text;
159
+ ta.style.position = 'fixed';
160
+ ta.style.opacity = '0';
161
+ document.body.appendChild(ta);
162
+ ta.select();
163
+ document.execCommand('copy');
164
+ document.body.removeChild(ta);
165
+ }
166
+ toast(`${label || 'Result'} copied`);
167
+ } catch (e) {
168
+ toast(`Copy failed: ${e.message}`);
169
+ }
170
+ }, [text, label, toast]);
171
+
172
+ return (
173
+ <button className="btn mini" onClick={onClick} disabled={!text}
174
+ style={{ opacity: text ? 1 : 0.4, cursor: text ? 'pointer' : 'default' }}>
175
+ Copy
176
+ </button>
177
+ );
178
+ }
179
+
180
+ function Segmented({ options, value, onChange }) {
181
+ return (
182
+ <div className="seg">
183
+ {options.map((o) => (
184
+ <button key={o.value}
185
+ className={'seg-btn' + (o.value === value ? ' active' : '')}
186
+ onClick={() => onChange(o.value)}>
187
+ {o.label}
188
+ </button>
189
+ ))}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ /** Input on the left, output on the right, controls above. */
195
+ function Workbench({ controls, value, onChange, placeholder, outputLabel, result }) {
196
+ const failed = !!(result && result.error);
197
+ const text = failed ? result.error : (result ? result.value : '');
198
+ return (
199
+ <div className="panel">
200
+ <div className="bar">{controls}</div>
201
+ <div className="cols">
202
+ <div className="box">
203
+ <div className="box-hd"><span>Input</span></div>
204
+ <div className="box-body">
205
+ <textarea className="io" value={value} spellCheck={false}
206
+ placeholder={placeholder}
207
+ onChange={(e) => onChange(e.target.value)} />
208
+ </div>
209
+ </div>
210
+ <div className="box">
211
+ <div className="box-hd">
212
+ <span>{failed ? 'Error' : (outputLabel || 'Output')}</span>
213
+ <CopyButton text={failed ? '' : text} label={outputLabel || 'Output'} />
214
+ </div>
215
+ <div className="box-body">
216
+ <pre className={'io' + (failed ? ' error' : '')}>{text}</pre>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ );
222
+ }
223
+
224
+ /* ------------------------------------------------------------------ panels */
225
+
226
+ function TextCodecPanel({ state, patch, kind }) {
227
+ const debounced = useDebounced(state.text, 150);
228
+
229
+ const result = useMemo(() => {
230
+ if (!debounced) return { value: '' };
231
+ try {
232
+ if (kind === 'base64') {
233
+ return { value: state.mode === 'encode' ? encodeBase64(debounced) : decodeBase64(debounced) };
234
+ }
235
+ return {
236
+ value: state.mode === 'encode'
237
+ ? encodeURIComponent(debounced)
238
+ : decodeURIComponent(debounced),
239
+ };
240
+ } catch (e) {
241
+ const hint = kind === 'base64'
242
+ ? 'Not valid Base64 — check for stray characters or wrong padding.'
243
+ : 'Not valid percent-encoding — check for a lone "%" or a bad escape.';
244
+ return { error: `${e.message}\n\n${hint}` };
245
+ }
246
+ }, [debounced, state.mode, kind]);
247
+
248
+ const controls = (
249
+ <>
250
+ <Segmented value={state.mode} onChange={(mode) => patch({ mode })}
251
+ options={[{ value: 'encode', label: 'Encode' }, { value: 'decode', label: 'Decode' }]} />
252
+ <button className="btn" onClick={() => patch({ text: '' })}>Clear</button>
253
+ <span className="bar-note">
254
+ {kind === 'base64' ? 'UTF-8 safe — handles non-ASCII text.' : 'encodeURIComponent / decodeURIComponent.'}
255
+ </span>
256
+ </>
257
+ );
258
+
259
+ return (
260
+ <Workbench controls={controls}
261
+ value={state.text}
262
+ onChange={(text) => patch({ text })}
263
+ placeholder={state.mode === 'encode' ? 'Text to encode…' : 'Text to decode…'}
264
+ outputLabel={state.mode === 'encode' ? 'Encoded' : 'Decoded'}
265
+ result={result} />
266
+ );
267
+ }
268
+
269
+ function JsonPanel({ state, patch }) {
270
+ const debounced = useDebounced(state.text, 150);
271
+
272
+ const result = useMemo(() => {
273
+ if (!debounced.trim()) return { value: '' };
274
+ try {
275
+ const parsed = JSON.parse(debounced);
276
+ return {
277
+ value: state.mode === 'pretty'
278
+ ? JSON.stringify(parsed, null, 2)
279
+ : JSON.stringify(parsed),
280
+ };
281
+ } catch (e) {
282
+ return { error: e.message };
283
+ }
284
+ }, [debounced, state.mode]);
285
+
286
+ const controls = (
287
+ <>
288
+ <Segmented value={state.mode} onChange={(mode) => patch({ mode })}
289
+ options={[{ value: 'pretty', label: 'Pretty' }, { value: 'minify', label: 'Minify' }]} />
290
+ <button className="btn" onClick={() => patch({ text: '' })}>Clear</button>
291
+ <span className="bar-note">Parses as you type — the error message doubles as validation.</span>
292
+ </>
293
+ );
294
+
295
+ return (
296
+ <Workbench controls={controls}
297
+ value={state.text}
298
+ onChange={(text) => patch({ text })}
299
+ placeholder='{"hello": "world"}'
300
+ outputLabel={state.mode === 'pretty' ? 'Formatted' : 'Minified'}
301
+ result={result} />
302
+ );
303
+ }
304
+
305
+ const HASH_ALGOS = [
306
+ { key: 'md5', label: 'MD5', subtle: null },
307
+ { key: 'sha1', label: 'SHA-1', subtle: 'SHA-1' },
308
+ { key: 'sha256', label: 'SHA-256', subtle: 'SHA-256' },
309
+ { key: 'sha512', label: 'SHA-512', subtle: 'SHA-512' },
310
+ ];
311
+
312
+ function HashPanel({ state, patch }) {
313
+ const debounced = useDebounced(state.text, 150);
314
+ const [digests, setDigests] = useState({});
315
+
316
+ useEffect(() => {
317
+ if (!debounced) { setDigests({}); return; }
318
+
319
+ let cancelled = false;
320
+ const bytes = new TextEncoder().encode(debounced);
321
+ const next = { md5: md5(bytes) };
322
+
323
+ if (!SUBTLE) {
324
+ setDigests(next);
325
+ return;
326
+ }
327
+
328
+ Promise.all(
329
+ HASH_ALGOS.filter((a) => a.subtle).map((a) =>
330
+ SUBTLE.digest(a.subtle, bytes).then((buf) => [a.key, toHex(buf)])
331
+ )
332
+ ).then((pairs) => {
333
+ if (cancelled) return;
334
+ for (const [key, hex] of pairs) next[key] = hex;
335
+ setDigests({ ...next });
336
+ }).catch(() => {
337
+ if (!cancelled) setDigests(next);
338
+ });
339
+
340
+ // Guard against an out-of-order resolve overwriting a newer input.
341
+ return () => { cancelled = true; };
342
+ }, [debounced]);
343
+
344
+ const controls = (
345
+ <>
346
+ <button className="btn" onClick={() => patch({ text: '' })}>Clear</button>
347
+ <span className="bar-note">
348
+ {SUBTLE
349
+ ? 'MD5 computed locally; SHA via the browser\'s WebCrypto.'
350
+ : 'SHA needs a secure context — open DevKit over localhost to enable it.'}
351
+ </span>
352
+ </>
353
+ );
354
+
355
+ return (
356
+ <div className="panel">
357
+ <div className="bar">{controls}</div>
358
+ <div className="cols">
359
+ <div className="box">
360
+ <div className="box-hd"><span>Input</span></div>
361
+ <div className="box-body">
362
+ <textarea className="io" value={state.text} spellCheck={false}
363
+ placeholder="Text to hash…"
364
+ onChange={(e) => patch({ text: e.target.value })} />
365
+ </div>
366
+ </div>
367
+ <div className="box">
368
+ <div className="box-hd"><span>Digests</span></div>
369
+ <div className="rows">
370
+ {HASH_ALGOS.map((algo) => {
371
+ const unavailable = algo.subtle && !SUBTLE;
372
+ const value = digests[algo.key];
373
+ const shown = unavailable
374
+ ? 'unavailable outside a secure context'
375
+ : (value || '—');
376
+ return (
377
+ <div className="row" key={algo.key}>
378
+ <span className="row-label">{algo.label}</span>
379
+ <span className={'row-value' + (value && !unavailable ? '' : ' muted')}>{shown}</span>
380
+ <CopyButton text={unavailable ? '' : (value || '')} label={algo.label} />
381
+ </div>
382
+ );
383
+ })}
384
+ </div>
385
+ </div>
386
+ </div>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ // Only the time claims get special treatment -- they are the ones unreadable as
392
+ // raw numbers. Everything else is already legible in the payload JSON above.
393
+ const TIME_CLAIMS = [
394
+ { key: 'exp', label: 'Expires' },
395
+ { key: 'iat', label: 'Issued' },
396
+ { key: 'nbf', label: 'Not before' },
397
+ ];
398
+
399
+ function JwtPanel({ state, patch }) {
400
+ const debounced = useDebounced(state.text, 150);
401
+ const decoded = useMemo(() => decodeJwt(debounced), [debounced]);
402
+
403
+ const headerJson = decoded && decoded.header ? JSON.stringify(decoded.header, null, 2) : '';
404
+ const payloadJson = decoded && decoded.payload ? JSON.stringify(decoded.payload, null, 2) : '';
405
+
406
+ const claims = useMemo(() => {
407
+ if (!decoded || !decoded.payload) return [];
408
+ return TIME_CLAIMS
409
+ .filter((c) => typeof decoded.payload[c.key] === 'number')
410
+ .map((c) => {
411
+ const seconds = decoded.payload[c.key];
412
+ const date = new Date(seconds * 1000);
413
+ const valid = !Number.isNaN(date.getTime());
414
+ return {
415
+ key: c.key,
416
+ label: c.label,
417
+ text: valid ? `${formatLocal(date)} (${relativeFromNow(date.getTime())})` : 'not a valid time',
418
+ // Only `exp` in the past is an actual problem; a past `iat` is normal.
419
+ bad: valid && c.key === 'exp' && date.getTime() < Date.now(),
420
+ };
421
+ });
422
+ }, [decoded]);
423
+
424
+ const algNone = decoded && decoded.header &&
425
+ typeof decoded.header.alg === 'string' && decoded.header.alg.toLowerCase() === 'none';
426
+
427
+ return (
428
+ <div className="panel">
429
+ <div className="bar">
430
+ <button className="btn" onClick={() => patch({ text: '' })}>Clear</button>
431
+ <span className="bar-note">Decoded, not verified — the signature is never checked.</span>
432
+ </div>
433
+
434
+ <div className="cols">
435
+ <div className="box">
436
+ <div className="box-hd"><span>Token</span></div>
437
+ <div className="box-body">
438
+ <textarea className="io" value={state.text} spellCheck={false}
439
+ placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…"
440
+ onChange={(e) => patch({ text: e.target.value })} />
441
+ </div>
442
+ </div>
443
+
444
+ <div className="box">
445
+ <div className="box-hd"><span>{decoded && decoded.error ? 'Error' : 'Decoded'}</span></div>
446
+ <div className="stack">
447
+ {!decoded && (
448
+ <div className="section"><span className="kv-v muted">Paste a JWT on the left.</span></div>
449
+ )}
450
+
451
+ {decoded && decoded.error && (
452
+ <div className="section"><pre className="mono-block error">{decoded.error}</pre></div>
453
+ )}
454
+
455
+ {decoded && decoded.header && (
456
+ <>
457
+ <div className="section">
458
+ <div className="section-hd">
459
+ <span className="section-title">Header</span>
460
+ <CopyButton text={headerJson} label="Header" />
461
+ </div>
462
+ <pre className="mono-block">{headerJson}</pre>
463
+ {algNone && (
464
+ <span className="kv-v error">alg is &quot;none&quot; — this token is unsigned.</span>
465
+ )}
466
+ </div>
467
+
468
+ <div className="section">
469
+ <div className="section-hd">
470
+ <span className="section-title">Payload</span>
471
+ <CopyButton text={payloadJson} label="Payload" />
472
+ </div>
473
+ <pre className="mono-block">{payloadJson}</pre>
474
+ </div>
475
+
476
+ {claims.length > 0 && (
477
+ <div className="section">
478
+ <span className="section-title">Time claims</span>
479
+ {claims.map((c) => (
480
+ <div className="kv" key={c.key}>
481
+ <span className="kv-k">{c.label}</span>
482
+ <span className={'kv-v' + (c.bad ? ' error' : '')}>
483
+ {c.text}{c.bad ? ' — expired' : ''}
484
+ </span>
485
+ </div>
486
+ ))}
487
+ </div>
488
+ )}
489
+
490
+ <div className="section">
491
+ <span className="section-title">Signature</span>
492
+ <pre className="mono-block muted">{decoded.signature || '(empty)'}</pre>
493
+ </div>
494
+ </>
495
+ )}
496
+ </div>
497
+ </div>
498
+ </div>
499
+ </div>
500
+ );
501
+ }
502
+
503
+ function TimestampPanel({ state, patch }) {
504
+ const [now, setNow] = useState(() => Date.now());
505
+
506
+ useEffect(() => {
507
+ const timer = setInterval(() => setNow(Date.now()), 1000);
508
+ return () => clearInterval(timer);
509
+ }, []);
510
+
511
+ const fromEpoch = useMemo(() => parseEpoch(state.epoch), [state.epoch]);
512
+
513
+ const fromDate = useMemo(() => {
514
+ const s = state.date.trim();
515
+ if (!s) return null;
516
+ // Normalise "2024-01-01 12:00:00" to the ISO-ish form Date reliably takes.
517
+ const date = new Date(/^\d{4}-\d{2}-\d{2} /.test(s) ? s.replace(' ', 'T') : s);
518
+ if (Number.isNaN(date.getTime())) {
519
+ return { error: 'Unrecognised date — try 2024-01-01 12:00:00 or an ISO string.' };
520
+ }
521
+ return { date };
522
+ }, [state.date]);
523
+
524
+ const nowSeconds = String(Math.floor(now / 1000));
525
+
526
+ return (
527
+ <div className="panel">
528
+ <div className="bar">
529
+ <span className="bar-note">Now</span>
530
+ <span style={{ fontFamily: 'var(--font-mono)' }}>{nowSeconds}</span>
531
+ <CopyButton text={nowSeconds} label="Timestamp (s)" />
532
+ <span style={{ fontFamily: 'var(--font-mono)' }}>{String(now)}</span>
533
+ <CopyButton text={String(now)} label="Timestamp (ms)" />
534
+ <span className="bar-note">{formatLocal(new Date(now))}</span>
535
+ </div>
536
+
537
+ <div className="cols">
538
+ <div className="box">
539
+ <div className="box-hd"><span>Timestamp → Date</span></div>
540
+ <div className="section">
541
+ <div className="bar">
542
+ <input className="text-in" value={state.epoch} spellCheck={false}
543
+ placeholder="1700000000"
544
+ onChange={(e) => patch({ epoch: e.target.value })} />
545
+ <button className="btn" onClick={() => patch({ epoch: nowSeconds })}>Now</button>
546
+ </div>
547
+ {!fromEpoch && <div className="kv"><span className="kv-v muted">Enter a timestamp above.</span></div>}
548
+ {fromEpoch && fromEpoch.error && (
549
+ <div className="kv"><span className="kv-v error">{fromEpoch.error}</span></div>
550
+ )}
551
+ {fromEpoch && fromEpoch.date && (
552
+ <>
553
+ <div className="kv"><span className="kv-k">Detected</span><span className="kv-v muted">{fromEpoch.unit}</span></div>
554
+ <div className="kv"><span className="kv-k">Local</span><span className="kv-v">{formatLocal(fromEpoch.date)}</span></div>
555
+ <div className="kv"><span className="kv-k">UTC</span><span className="kv-v">{formatUTC(fromEpoch.date)}</span></div>
556
+ <div className="kv"><span className="kv-k">ISO</span><span className="kv-v">{fromEpoch.date.toISOString()}</span></div>
557
+ </>
558
+ )}
559
+ </div>
560
+ </div>
561
+
562
+ <div className="box">
563
+ <div className="box-hd"><span>Date → Timestamp</span></div>
564
+ <div className="section">
565
+ <div className="bar">
566
+ <input className="text-in" value={state.date} spellCheck={false}
567
+ placeholder="2024-01-01 12:00:00"
568
+ onChange={(e) => patch({ date: e.target.value })} />
569
+ <button className="btn" onClick={() => patch({ date: formatLocal(new Date(now)) })}>Now</button>
570
+ </div>
571
+ {!fromDate && <div className="kv"><span className="kv-v muted">Enter a date above.</span></div>}
572
+ {fromDate && fromDate.error && (
573
+ <div className="kv"><span className="kv-v error">{fromDate.error}</span></div>
574
+ )}
575
+ {fromDate && fromDate.date && (
576
+ <>
577
+ <div className="kv">
578
+ <span className="kv-k">Seconds</span>
579
+ <span className="kv-v">{Math.floor(fromDate.date.getTime() / 1000)}</span>
580
+ <CopyButton text={String(Math.floor(fromDate.date.getTime() / 1000))} label="Seconds" />
581
+ </div>
582
+ <div className="kv">
583
+ <span className="kv-k">Millis</span>
584
+ <span className="kv-v">{fromDate.date.getTime()}</span>
585
+ <CopyButton text={String(fromDate.date.getTime())} label="Milliseconds" />
586
+ </div>
587
+ <div className="kv"><span className="kv-k">ISO</span><span className="kv-v">{fromDate.date.toISOString()}</span></div>
588
+ </>
589
+ )}
590
+ </div>
591
+ </div>
592
+ </div>
593
+ </div>
594
+ );
595
+ }
596
+
597
+ function UuidPanel({ state, patch }) {
598
+ const generate = useCallback((count) => {
599
+ const list = [];
600
+ for (let i = 0; i < count; i++) list.push(uuidV4());
601
+ patch({ list });
602
+ }, [patch]);
603
+
604
+ // Generate once on first mount so the panel is never empty.
605
+ const listRef = useRef(state.list);
606
+ listRef.current = state.list;
607
+ useEffect(() => {
608
+ if (listRef.current.length === 0) generate(state.count);
609
+ }, []);
610
+
611
+ const shown = useMemo(() => state.list.map((u) => {
612
+ let v = state.dashes ? u : u.replace(/-/g, '');
613
+ return state.upper ? v.toUpperCase() : v;
614
+ }), [state.list, state.dashes, state.upper]);
615
+
616
+ const text = shown.join('\n');
617
+
618
+ return (
619
+ <div className="panel">
620
+ <div className="bar">
621
+ <button className="btn" onClick={() => generate(state.count)}>Generate</button>
622
+ <label className="check">
623
+ Count
624
+ <input className="num" type="number" min="1" max="50" value={state.count}
625
+ onChange={(e) => {
626
+ const n = Math.min(50, Math.max(1, Number(e.target.value) || 1));
627
+ patch({ count: n });
628
+ }} />
629
+ </label>
630
+ <label className="check">
631
+ <input type="checkbox" checked={state.upper} onChange={(e) => patch({ upper: e.target.checked })} />
632
+ Uppercase
633
+ </label>
634
+ <label className="check">
635
+ <input type="checkbox" checked={state.dashes} onChange={(e) => patch({ dashes: e.target.checked })} />
636
+ Dashes
637
+ </label>
638
+ <span className="bar-note">v4, from crypto.getRandomValues.</span>
639
+ </div>
640
+
641
+ <div className="cols" style={{ gridTemplateColumns: '1fr' }}>
642
+ <div className="box">
643
+ <div className="box-hd">
644
+ <span>{shown.length} UUID{shown.length === 1 ? '' : 's'}</span>
645
+ <CopyButton text={text} label="UUIDs" />
646
+ </div>
647
+ <div className="box-body">
648
+ <pre className="io">{text}</pre>
649
+ </div>
650
+ </div>
651
+ </div>
652
+ </div>
653
+ );
654
+ }
655
+
656
+ /* --------------------------------------------------------------------- app */
657
+
658
+ const TABS = [
659
+ { id: 'base64', label: 'Base64' },
660
+ { id: 'url', label: 'URL' },
661
+ { id: 'json', label: 'JSON' },
662
+ { id: 'hash', label: 'Hash' },
663
+ { id: 'jwt', label: 'JWT' },
664
+ { id: 'timestamp', label: 'Timestamp' },
665
+ { id: 'uuid', label: 'UUID' },
666
+ ];
667
+
668
+ const INITIAL = {
669
+ base64: { mode: 'encode', text: '' },
670
+ url: { mode: 'encode', text: '' },
671
+ json: { mode: 'pretty', text: '' },
672
+ hash: { text: '' },
673
+ jwt: { text: '' },
674
+ timestamp: { epoch: '', date: '' },
675
+ uuid: { count: 5, upper: false, dashes: true, list: [] },
676
+ };
677
+
678
+ function App() {
679
+ const [active, setActive] = useState('base64');
680
+ // Every tool's state lives here, so switching tabs never loses input even
681
+ // though only the active panel is mounted.
682
+ const [state, setState] = useState(INITIAL);
683
+ const [toastMsg, setToastMsg] = useState(null);
684
+
685
+ const toastTimerRef = useRef(null);
686
+ const toast = useCallback((message) => {
687
+ setToastMsg(message);
688
+ clearTimeout(toastTimerRef.current);
689
+ toastTimerRef.current = setTimeout(() => setToastMsg(null), 1600);
690
+ }, []);
691
+ useEffect(() => () => clearTimeout(toastTimerRef.current), []);
692
+
693
+ const patchers = useMemo(() => {
694
+ const out = {};
695
+ for (const tab of TABS) {
696
+ out[tab.id] = (changes) =>
697
+ setState((prev) => ({ ...prev, [tab.id]: { ...prev[tab.id], ...changes } }));
698
+ }
699
+ return out;
700
+ }, []);
701
+
702
+ let panel;
703
+ if (active === 'base64') panel = <TextCodecPanel kind="base64" state={state.base64} patch={patchers.base64} />;
704
+ else if (active === 'url') panel = <TextCodecPanel kind="url" state={state.url} patch={patchers.url} />;
705
+ else if (active === 'json') panel = <JsonPanel state={state.json} patch={patchers.json} />;
706
+ else if (active === 'hash') panel = <HashPanel state={state.hash} patch={patchers.hash} />;
707
+ else if (active === 'jwt') panel = <JwtPanel state={state.jwt} patch={patchers.jwt} />;
708
+ else if (active === 'timestamp') panel = <TimestampPanel state={state.timestamp} patch={patchers.timestamp} />;
709
+ else panel = <UuidPanel state={state.uuid} patch={patchers.uuid} />;
710
+
711
+ return (
712
+ <ToastContext.Provider value={toast}>
713
+ <div className="app">
714
+ <div className="tabs">
715
+ {TABS.map((tab) => (
716
+ <button key={tab.id}
717
+ className={'tab' + (tab.id === active ? ' active' : '')}
718
+ onClick={() => setActive(tab.id)}>
719
+ {tab.label}
720
+ </button>
721
+ ))}
722
+ </div>
723
+ {panel}
724
+ {toastMsg && <div className="toast">{toastMsg}</div>}
725
+ </div>
726
+ </ToastContext.Provider>
727
+ );
728
+ }
729
+
730
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);