hazo_auth 1.4.2 → 1.6.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 (320) hide show
  1. package/SETUP_CHECKLIST.md +708 -0
  2. package/dist/app/api/hazo_auth/change_password/route.d.ts +8 -0
  3. package/dist/app/api/hazo_auth/change_password/route.d.ts.map +1 -0
  4. package/dist/app/api/hazo_auth/change_password/route.js +98 -0
  5. package/dist/app/api/hazo_auth/forgot_password/route.d.ts +8 -0
  6. package/dist/app/api/hazo_auth/forgot_password/route.d.ts.map +1 -0
  7. package/dist/app/api/hazo_auth/forgot_password/route.js +78 -0
  8. package/dist/app/api/hazo_auth/get_auth/route.d.ts +10 -0
  9. package/dist/app/api/hazo_auth/get_auth/route.d.ts.map +1 -0
  10. package/dist/app/api/hazo_auth/get_auth/route.js +63 -0
  11. package/dist/app/api/hazo_auth/invalidate_cache/route.d.ts +14 -0
  12. package/dist/app/api/hazo_auth/invalidate_cache/route.d.ts.map +1 -0
  13. package/dist/app/api/hazo_auth/invalidate_cache/route.js +96 -0
  14. package/dist/app/api/hazo_auth/library_photos/route.d.ts +13 -0
  15. package/dist/app/api/hazo_auth/library_photos/route.d.ts.map +1 -0
  16. package/dist/app/api/hazo_auth/library_photos/route.js +55 -0
  17. package/dist/app/api/hazo_auth/login/route.d.ts +12 -0
  18. package/dist/app/api/hazo_auth/login/route.d.ts.map +1 -0
  19. package/dist/app/api/hazo_auth/login/route.js +140 -0
  20. package/dist/app/api/hazo_auth/logout/route.d.ts +8 -0
  21. package/dist/app/api/hazo_auth/logout/route.d.ts.map +1 -0
  22. package/dist/app/api/hazo_auth/logout/route.js +71 -0
  23. package/dist/app/api/hazo_auth/me/route.d.ts +3 -0
  24. package/dist/app/api/hazo_auth/me/route.d.ts.map +1 -0
  25. package/dist/app/api/hazo_auth/me/route.js +34 -0
  26. package/dist/app/api/hazo_auth/profile_picture/[filename]/route.d.ts +7 -0
  27. package/dist/app/api/hazo_auth/profile_picture/[filename]/route.d.ts.map +1 -0
  28. package/dist/app/api/hazo_auth/profile_picture/[filename]/route.js +43 -0
  29. package/dist/app/api/hazo_auth/register/route.d.ts +9 -0
  30. package/dist/app/api/hazo_auth/register/route.d.ts.map +1 -0
  31. package/dist/app/api/hazo_auth/register/route.js +80 -0
  32. package/dist/app/api/hazo_auth/remove_profile_picture/route.d.ts +8 -0
  33. package/dist/app/api/hazo_auth/remove_profile_picture/route.d.ts.map +1 -0
  34. package/dist/app/api/hazo_auth/remove_profile_picture/route.js +64 -0
  35. package/dist/app/api/hazo_auth/resend_verification/route.d.ts +8 -0
  36. package/dist/app/api/hazo_auth/resend_verification/route.d.ts.map +1 -0
  37. package/dist/app/api/hazo_auth/resend_verification/route.js +79 -0
  38. package/dist/app/api/hazo_auth/reset_password/route.d.ts +8 -0
  39. package/dist/app/api/hazo_auth/reset_password/route.d.ts.map +1 -0
  40. package/dist/app/api/hazo_auth/reset_password/route.js +76 -0
  41. package/dist/app/api/hazo_auth/update_user/route.d.ts +9 -0
  42. package/dist/app/api/hazo_auth/update_user/route.d.ts.map +1 -0
  43. package/dist/app/api/hazo_auth/update_user/route.js +95 -0
  44. package/dist/app/api/hazo_auth/upload_profile_picture/route.d.ts +9 -0
  45. package/dist/app/api/hazo_auth/upload_profile_picture/route.d.ts.map +1 -0
  46. package/dist/app/api/hazo_auth/upload_profile_picture/route.js +204 -0
  47. package/dist/app/api/hazo_auth/validate_reset_token/route.d.ts +6 -0
  48. package/dist/app/api/hazo_auth/validate_reset_token/route.d.ts.map +1 -0
  49. package/dist/app/api/hazo_auth/validate_reset_token/route.js +58 -0
  50. package/dist/app/api/hazo_auth/verify_email/route.d.ts +11 -0
  51. package/dist/app/api/hazo_auth/verify_email/route.d.ts.map +1 -0
  52. package/dist/app/api/hazo_auth/verify_email/route.js +63 -0
  53. package/dist/cli/generate.d.ts +2 -0
  54. package/dist/cli/generate.d.ts.map +1 -0
  55. package/dist/cli/generate.js +117 -0
  56. package/dist/cli/index.d.ts +3 -0
  57. package/dist/cli/index.d.ts.map +1 -0
  58. package/dist/cli/index.js +120 -0
  59. package/dist/cli/validate.d.ts +15 -0
  60. package/dist/cli/validate.d.ts.map +1 -0
  61. package/dist/cli/validate.js +509 -0
  62. package/dist/components/ui/card.d.ts +9 -0
  63. package/dist/components/ui/card.d.ts.map +1 -0
  64. package/dist/components/ui/card.js +45 -0
  65. package/dist/hooks/use-mobile.d.ts.map +1 -1
  66. package/dist/hooks/use-mobile.js +17 -3
  67. package/dist/server/routes/change_password.d.ts +2 -0
  68. package/dist/server/routes/change_password.d.ts.map +1 -0
  69. package/dist/server/routes/change_password.js +2 -0
  70. package/dist/server/routes/forgot_password.d.ts +2 -0
  71. package/dist/server/routes/forgot_password.d.ts.map +1 -0
  72. package/dist/server/routes/forgot_password.js +2 -0
  73. package/dist/server/routes/get_auth.d.ts +2 -0
  74. package/dist/server/routes/get_auth.d.ts.map +1 -0
  75. package/dist/server/routes/get_auth.js +2 -0
  76. package/dist/server/routes/index.d.ts +18 -0
  77. package/dist/server/routes/index.d.ts.map +1 -0
  78. package/dist/server/routes/index.js +24 -0
  79. package/dist/server/routes/invalidate_cache.d.ts +2 -0
  80. package/dist/server/routes/invalidate_cache.d.ts.map +1 -0
  81. package/dist/server/routes/invalidate_cache.js +2 -0
  82. package/dist/server/routes/library_photos.d.ts +2 -0
  83. package/dist/server/routes/library_photos.d.ts.map +1 -0
  84. package/dist/server/routes/library_photos.js +2 -0
  85. package/dist/server/routes/login.d.ts +2 -0
  86. package/dist/server/routes/login.d.ts.map +1 -0
  87. package/dist/server/routes/login.js +2 -0
  88. package/dist/server/routes/logout.d.ts +2 -0
  89. package/dist/server/routes/logout.d.ts.map +1 -0
  90. package/dist/server/routes/logout.js +2 -0
  91. package/dist/server/routes/me.d.ts +2 -0
  92. package/dist/server/routes/me.d.ts.map +1 -0
  93. package/dist/server/routes/me.js +2 -0
  94. package/dist/server/routes/profile_picture_filename.d.ts +2 -0
  95. package/dist/server/routes/profile_picture_filename.d.ts.map +1 -0
  96. package/dist/server/routes/profile_picture_filename.js +3 -0
  97. package/dist/server/routes/register.d.ts +2 -0
  98. package/dist/server/routes/register.d.ts.map +1 -0
  99. package/dist/server/routes/register.js +2 -0
  100. package/dist/server/routes/remove_profile_picture.d.ts +2 -0
  101. package/dist/server/routes/remove_profile_picture.d.ts.map +1 -0
  102. package/dist/server/routes/remove_profile_picture.js +2 -0
  103. package/dist/server/routes/resend_verification.d.ts +2 -0
  104. package/dist/server/routes/resend_verification.d.ts.map +1 -0
  105. package/dist/server/routes/resend_verification.js +2 -0
  106. package/dist/server/routes/reset_password.d.ts +2 -0
  107. package/dist/server/routes/reset_password.d.ts.map +1 -0
  108. package/dist/server/routes/reset_password.js +2 -0
  109. package/dist/server/routes/update_user.d.ts +2 -0
  110. package/dist/server/routes/update_user.d.ts.map +1 -0
  111. package/dist/server/routes/update_user.js +2 -0
  112. package/dist/server/routes/upload_profile_picture.d.ts +2 -0
  113. package/dist/server/routes/upload_profile_picture.d.ts.map +1 -0
  114. package/dist/server/routes/upload_profile_picture.js +2 -0
  115. package/dist/server/routes/validate_reset_token.d.ts +2 -0
  116. package/dist/server/routes/validate_reset_token.d.ts.map +1 -0
  117. package/dist/server/routes/validate_reset_token.js +2 -0
  118. package/dist/server/routes/verify_email.d.ts +2 -0
  119. package/dist/server/routes/verify_email.d.ts.map +1 -0
  120. package/dist/server/routes/verify_email.js +2 -0
  121. package/package.json +12 -17
  122. package/components.json +0 -22
  123. package/instrumentation.ts +0 -32
  124. package/migrations/001_add_token_type_to_refresh_tokens.sql +0 -14
  125. package/migrations/002_add_name_to_hazo_users.sql +0 -7
  126. package/migrations/003_add_url_on_logon_to_hazo_users.sql +0 -8
  127. package/next.config.mjs +0 -67
  128. package/postcss.config.mjs +0 -8
  129. package/public/file.svg +0 -1
  130. package/public/globe.svg +0 -1
  131. package/public/next.svg +0 -1
  132. package/public/vercel.svg +0 -1
  133. package/public/window.svg +0 -1
  134. package/scripts/apply_migration.ts +0 -118
  135. package/scripts/init_users.ts +0 -378
  136. package/src/app/api/hazo_auth/auth/upload_profile_picture/route.ts +0 -268
  137. package/src/app/api/hazo_auth/change_password/route.ts +0 -132
  138. package/src/app/api/hazo_auth/forgot_password/route.ts +0 -107
  139. package/src/app/api/hazo_auth/get_auth/route.ts +0 -89
  140. package/src/app/api/hazo_auth/invalidate_cache/route.ts +0 -139
  141. package/src/app/api/hazo_auth/library_photos/route.ts +0 -73
  142. package/src/app/api/hazo_auth/login/route.ts +0 -181
  143. package/src/app/api/hazo_auth/logout/route.ts +0 -89
  144. package/src/app/api/hazo_auth/me/route.ts +0 -47
  145. package/src/app/api/hazo_auth/profile_picture/[filename]/route.ts +0 -67
  146. package/src/app/api/hazo_auth/register/route.ts +0 -109
  147. package/src/app/api/hazo_auth/remove_profile_picture/route.ts +0 -86
  148. package/src/app/api/hazo_auth/resend_verification/route.ts +0 -108
  149. package/src/app/api/hazo_auth/reset_password/route.ts +0 -107
  150. package/src/app/api/hazo_auth/update_user/route.ts +0 -126
  151. package/src/app/api/hazo_auth/upload_profile_picture/route.ts +0 -268
  152. package/src/app/api/hazo_auth/user_management/permissions/route.ts +0 -367
  153. package/src/app/api/hazo_auth/user_management/roles/route.ts +0 -442
  154. package/src/app/api/hazo_auth/user_management/users/roles/route.ts +0 -367
  155. package/src/app/api/hazo_auth/user_management/users/route.ts +0 -239
  156. package/src/app/api/hazo_auth/validate_reset_token/route.ts +0 -83
  157. package/src/app/api/hazo_auth/verify_email/route.ts +0 -88
  158. package/src/app/api/migrations/apply/route.ts +0 -91
  159. package/src/app/favicon.ico +0 -0
  160. package/src/app/fonts/GeistMonoVF.woff +0 -0
  161. package/src/app/fonts/GeistVF.woff +0 -0
  162. package/src/app/globals.css +0 -89
  163. package/src/app/hazo_auth/forgot_password/forgot_password_page_client.tsx +0 -60
  164. package/src/app/hazo_auth/forgot_password/page.tsx +0 -24
  165. package/src/app/hazo_auth/login/login_page_client.tsx +0 -86
  166. package/src/app/hazo_auth/login/page.tsx +0 -38
  167. package/src/app/hazo_auth/my_settings/my_settings_page_client.tsx +0 -120
  168. package/src/app/hazo_auth/my_settings/page.tsx +0 -40
  169. package/src/app/hazo_auth/register/page.tsx +0 -36
  170. package/src/app/hazo_auth/register/register_page_client.tsx +0 -81
  171. package/src/app/hazo_auth/reset_password/page.tsx +0 -29
  172. package/src/app/hazo_auth/reset_password/reset_password_page_client.tsx +0 -81
  173. package/src/app/hazo_auth/user_management/page.tsx +0 -14
  174. package/src/app/hazo_auth/user_management/user_management_page_client.tsx +0 -16
  175. package/src/app/hazo_auth/verify_email/page.tsx +0 -24
  176. package/src/app/hazo_auth/verify_email/verify_email_page_client.tsx +0 -60
  177. package/src/app/hazo_connect/api/sqlite/data/route.ts +0 -203
  178. package/src/app/hazo_connect/api/sqlite/schema/route.ts +0 -45
  179. package/src/app/hazo_connect/api/sqlite/tables/route.ts +0 -36
  180. package/src/app/hazo_connect/sqlite_admin/page.tsx +0 -51
  181. package/src/app/hazo_connect/sqlite_admin/sqlite-admin-client.tsx +0 -984
  182. package/src/app/layout.tsx +0 -43
  183. package/src/app/page.tsx +0 -170
  184. package/src/components/index.ts +0 -7
  185. package/src/components/layouts/email_verification/config/email_verification_field_config.ts +0 -86
  186. package/src/components/layouts/email_verification/hooks/use_email_verification.ts +0 -297
  187. package/src/components/layouts/email_verification/index.tsx +0 -297
  188. package/src/components/layouts/forgot_password/config/forgot_password_field_config.ts +0 -58
  189. package/src/components/layouts/forgot_password/hooks/use_forgot_password_form.ts +0 -179
  190. package/src/components/layouts/forgot_password/index.tsx +0 -168
  191. package/src/components/layouts/index.ts +0 -26
  192. package/src/components/layouts/login/config/login_field_config.ts +0 -67
  193. package/src/components/layouts/login/hooks/use_login_form.ts +0 -286
  194. package/src/components/layouts/login/index.tsx +0 -252
  195. package/src/components/layouts/my_settings/components/editable_field.tsx +0 -177
  196. package/src/components/layouts/my_settings/components/password_change_dialog.tsx +0 -301
  197. package/src/components/layouts/my_settings/components/profile_picture_dialog.tsx +0 -385
  198. package/src/components/layouts/my_settings/components/profile_picture_display.tsx +0 -66
  199. package/src/components/layouts/my_settings/components/profile_picture_gravatar_tab.tsx +0 -143
  200. package/src/components/layouts/my_settings/components/profile_picture_library_tab.tsx +0 -311
  201. package/src/components/layouts/my_settings/components/profile_picture_upload_tab.tsx +0 -341
  202. package/src/components/layouts/my_settings/config/my_settings_field_config.ts +0 -61
  203. package/src/components/layouts/my_settings/hooks/use_my_settings.ts +0 -458
  204. package/src/components/layouts/my_settings/index.tsx +0 -351
  205. package/src/components/layouts/register/config/register_field_config.ts +0 -101
  206. package/src/components/layouts/register/hooks/use_register_form.ts +0 -275
  207. package/src/components/layouts/register/index.tsx +0 -226
  208. package/src/components/layouts/reset_password/config/reset_password_field_config.ts +0 -86
  209. package/src/components/layouts/reset_password/hooks/use_reset_password_form.ts +0 -276
  210. package/src/components/layouts/reset_password/index.tsx +0 -294
  211. package/src/components/layouts/shared/components/already_logged_in_guard.tsx +0 -95
  212. package/src/components/layouts/shared/components/auth_page_shell.tsx +0 -36
  213. package/src/components/layouts/shared/components/field_error_message.tsx +0 -29
  214. package/src/components/layouts/shared/components/form_action_buttons.tsx +0 -64
  215. package/src/components/layouts/shared/components/form_field_wrapper.tsx +0 -44
  216. package/src/components/layouts/shared/components/form_header.tsx +0 -36
  217. package/src/components/layouts/shared/components/logout_button.tsx +0 -76
  218. package/src/components/layouts/shared/components/password_field.tsx +0 -72
  219. package/src/components/layouts/shared/components/profile_pic_menu.tsx +0 -321
  220. package/src/components/layouts/shared/components/profile_pic_menu_wrapper.tsx +0 -40
  221. package/src/components/layouts/shared/components/sidebar_layout_wrapper.tsx +0 -214
  222. package/src/components/layouts/shared/components/standalone_layout_wrapper.tsx +0 -53
  223. package/src/components/layouts/shared/components/two_column_auth_layout.tsx +0 -44
  224. package/src/components/layouts/shared/components/unauthorized_guard.tsx +0 -78
  225. package/src/components/layouts/shared/components/visual_panel.tsx +0 -41
  226. package/src/components/layouts/shared/config/layout_customization.ts +0 -95
  227. package/src/components/layouts/shared/data/layout_data_client.ts +0 -19
  228. package/src/components/layouts/shared/hooks/use_auth_status.ts +0 -103
  229. package/src/components/layouts/shared/hooks/use_hazo_auth.ts +0 -158
  230. package/src/components/layouts/shared/index.ts +0 -34
  231. package/src/components/layouts/shared/utils/ip_address.ts +0 -37
  232. package/src/components/layouts/shared/utils/validation.ts +0 -66
  233. package/src/components/layouts/user_management/components/roles_matrix.tsx +0 -607
  234. package/src/components/layouts/user_management/index.tsx +0 -1295
  235. package/src/components/ui/alert-dialog.tsx +0 -141
  236. package/src/components/ui/avatar.tsx +0 -50
  237. package/src/components/ui/button.tsx +0 -57
  238. package/src/components/ui/checkbox.tsx +0 -30
  239. package/src/components/ui/dialog.tsx +0 -122
  240. package/src/components/ui/dropdown-menu.tsx +0 -201
  241. package/src/components/ui/hazo_ui_tooltip.tsx +0 -67
  242. package/src/components/ui/index.ts +0 -22
  243. package/src/components/ui/input.tsx +0 -22
  244. package/src/components/ui/label.tsx +0 -26
  245. package/src/components/ui/separator.tsx +0 -31
  246. package/src/components/ui/sheet.tsx +0 -139
  247. package/src/components/ui/sidebar.tsx +0 -773
  248. package/src/components/ui/skeleton.tsx +0 -15
  249. package/src/components/ui/sonner.tsx +0 -31
  250. package/src/components/ui/switch.tsx +0 -29
  251. package/src/components/ui/table.tsx +0 -120
  252. package/src/components/ui/tabs.tsx +0 -55
  253. package/src/components/ui/tooltip.tsx +0 -32
  254. package/src/components/ui/vertical-tabs.tsx +0 -59
  255. package/src/hooks/use-mobile.tsx +0 -19
  256. package/src/index.ts +0 -7
  257. package/src/lib/already_logged_in_config.server.ts +0 -46
  258. package/src/lib/app_logger.ts +0 -24
  259. package/src/lib/auth/auth_cache.ts +0 -220
  260. package/src/lib/auth/auth_rate_limiter.ts +0 -121
  261. package/src/lib/auth/auth_types.ts +0 -65
  262. package/src/lib/auth/auth_utils.server.ts +0 -196
  263. package/src/lib/auth/hazo_get_auth.server.ts +0 -333
  264. package/src/lib/auth/index.ts +0 -23
  265. package/src/lib/auth/server_auth.ts +0 -88
  266. package/src/lib/auth_utility_config.server.ts +0 -136
  267. package/src/lib/config/config_loader.server.ts +0 -164
  268. package/src/lib/email_verification_config.server.ts +0 -32
  269. package/src/lib/file_types_config.server.ts +0 -25
  270. package/src/lib/forgot_password_config.server.ts +0 -32
  271. package/src/lib/hazo_connect_instance.server.ts +0 -101
  272. package/src/lib/hazo_connect_setup.server.ts +0 -194
  273. package/src/lib/hazo_connect_setup.ts +0 -54
  274. package/src/lib/index.ts +0 -44
  275. package/src/lib/login_config.server.ts +0 -71
  276. package/src/lib/messages_config.server.ts +0 -45
  277. package/src/lib/migrations/apply_migration.ts +0 -105
  278. package/src/lib/my_settings_config.server.ts +0 -135
  279. package/src/lib/password_requirements_config.server.ts +0 -39
  280. package/src/lib/profile_pic_menu_config.server.ts +0 -138
  281. package/src/lib/profile_picture_config.server.ts +0 -56
  282. package/src/lib/register_config.server.ts +0 -73
  283. package/src/lib/reset_password_config.server.ts +0 -75
  284. package/src/lib/services/email_service.ts +0 -581
  285. package/src/lib/services/email_verification_service.ts +0 -270
  286. package/src/lib/services/index.ts +0 -15
  287. package/src/lib/services/login_service.ts +0 -134
  288. package/src/lib/services/password_change_service.ts +0 -154
  289. package/src/lib/services/password_reset_service.ts +0 -405
  290. package/src/lib/services/profile_picture_remove_service.ts +0 -120
  291. package/src/lib/services/profile_picture_service.ts +0 -215
  292. package/src/lib/services/profile_picture_source_mapper.ts +0 -62
  293. package/src/lib/services/registration_service.ts +0 -184
  294. package/src/lib/services/token_service.ts +0 -240
  295. package/src/lib/services/user_profiles_service.ts +0 -143
  296. package/src/lib/services/user_update_service.ts +0 -141
  297. package/src/lib/ui_shell_config.server.ts +0 -73
  298. package/src/lib/ui_sizes_config.server.ts +0 -37
  299. package/src/lib/user_fields_config.server.ts +0 -31
  300. package/src/lib/user_management_config.server.ts +0 -39
  301. package/src/lib/utils/api_route_helpers.ts +0 -60
  302. package/src/lib/utils/error_sanitizer.ts +0 -75
  303. package/src/lib/utils.ts +0 -11
  304. package/src/middleware.ts +0 -94
  305. package/src/routes/index.ts +0 -34
  306. package/src/server/config/config_loader.ts +0 -496
  307. package/src/server/index.ts +0 -38
  308. package/src/server/logging/logger_service.ts +0 -56
  309. package/src/server/routes/root_router.ts +0 -16
  310. package/src/server/server.ts +0 -28
  311. package/src/server/types/app_types.ts +0 -74
  312. package/src/server/types/express.d.ts +0 -16
  313. package/src/stories/email_verification_layout.stories.tsx +0 -137
  314. package/src/stories/forgot_password_layout.stories.tsx +0 -85
  315. package/src/stories/login_layout.stories.tsx +0 -85
  316. package/src/stories/project_overview.stories.tsx +0 -33
  317. package/src/stories/register_layout.stories.tsx +0 -107
  318. package/tailwind.config.ts +0 -77
  319. package/tsconfig.build.json +0 -36
  320. package/tsconfig.json +0 -28
@@ -1,984 +0,0 @@
1
- // file_description: SQLite admin UI client component for browsing and editing database tables
2
- "use client"
3
-
4
- import { useEffect, useMemo, useState } from "react"
5
- import {
6
- Filter,
7
- Loader2,
8
- Pencil,
9
- Plus,
10
- RefreshCw,
11
- Save,
12
- Trash2,
13
- X
14
- } from "lucide-react"
15
- import { toast } from "sonner"
16
- import type {
17
- SqliteFilterOperator,
18
- TableColumn,
19
- TableSchema,
20
- TableSummary
21
- } from "hazo_connect/ui"
22
-
23
- type FilterState = {
24
- column?: string
25
- operator: SqliteFilterOperator
26
- value: string
27
- }
28
-
29
- type SqlValue = Record<string, unknown>
30
- type DataResponse = {
31
- data: SqlValue[]
32
- total: number
33
- }
34
-
35
- const DEFAULT_LIMIT = 20
36
- const filterOperators: { label: string; value: SqliteFilterOperator }[] = [
37
- { label: "Equals", value: "eq" },
38
- { label: "Not equal", value: "neq" },
39
- { label: "Greater than", value: "gt" },
40
- { label: "Greater or equal", value: "gte" },
41
- { label: "Less than", value: "lt" },
42
- { label: "Less or equal", value: "lte" },
43
- { label: "Contains", value: "like" },
44
- { label: "Contains (case-insensitive)", value: "ilike" },
45
- { label: "Is / Is Not", value: "is" }
46
- ]
47
-
48
- export default function SqliteAdminClient({
49
- initialTables
50
- }: {
51
- initialTables: TableSummary[]
52
- }) {
53
- const [tables, setTables] = useState<TableSummary[]>(initialTables)
54
- const [selectedTable, setSelectedTable] = useState<TableSummary | null>(
55
- initialTables[0] ?? null
56
- )
57
- const [schema, setSchema] = useState<TableSchema | null>(null)
58
- const [rows, setRows] = useState<SqlValue[]>([])
59
- const [totalRows, setTotalRows] = useState(0)
60
- const [limit, setLimit] = useState(DEFAULT_LIMIT)
61
- const [offset, setOffset] = useState(0)
62
- const [orderBy, setOrderBy] = useState<string | undefined>()
63
- const [orderDirection, setOrderDirection] = useState<"asc" | "desc">("asc")
64
- const [filterState, setFilterState] = useState<FilterState>({
65
- operator: "eq",
66
- value: ""
67
- })
68
-
69
- const [isLoadingSchema, setIsLoadingSchema] = useState(false)
70
- const [isLoadingData, setIsLoadingData] = useState(false)
71
- const [isRefreshingTables, setIsRefreshingTables] = useState(false)
72
- const [isCreateOpen, setIsCreateOpen] = useState(false)
73
- const [editingRow, setEditingRow] = useState<SqlValue | null>(null)
74
-
75
- const columns = useMemo<TableColumn[]>(() => schema?.columns ?? [], [schema])
76
- const filterColumns = useMemo(
77
- () => columns.filter(column => identifierIsFilterable(column.name)),
78
- [columns]
79
- )
80
-
81
- useEffect(() => {
82
- if (!selectedTable) {
83
- return
84
- }
85
- void loadSchemaAndData(selectedTable.name)
86
- // eslint-disable-next-line react-hooks/exhaustive-deps
87
- }, [selectedTable?.name])
88
-
89
- async function loadSchemaAndData(tableName: string) {
90
- setIsLoadingSchema(true)
91
- try {
92
- const schemaResponse = await fetch(`/hazo_connect/api/sqlite/schema?table=${encodeURIComponent(tableName)}`)
93
- if (!schemaResponse.ok) {
94
- const contentType = schemaResponse.headers.get("content-type");
95
- if (contentType && contentType.includes("application/json")) {
96
- const errorData = await schemaResponse.json();
97
- throw new Error(errorData.error || `Failed to fetch schema: ${schemaResponse.statusText}`);
98
- } else {
99
- const errorText = await schemaResponse.text();
100
- throw new Error(`Failed to fetch schema: ${errorText.substring(0, 200)}`);
101
- }
102
- }
103
- const contentType = schemaResponse.headers.get("content-type");
104
- if (!contentType || !contentType.includes("application/json")) {
105
- const text = await schemaResponse.text();
106
- throw new Error(`Expected JSON but received ${contentType || "unknown content type"}. Response: ${text.substring(0, 200)}`);
107
- }
108
- const schemaJson = await schemaResponse.json()
109
- setSchema(schemaJson.data as TableSchema)
110
- const preferredFilterColumn =
111
- filterState.column && schemaJson.data?.columns?.some((col: TableColumn) => col.name === filterState.column)
112
- ? filterState.column
113
- : schemaJson.data?.columns?.[0]?.name
114
- setFilterState(current => ({
115
- ...current,
116
- column: preferredFilterColumn
117
- }))
118
- setLimit(DEFAULT_LIMIT)
119
- setOffset(0)
120
- setOrderBy(undefined)
121
- setOrderDirection("asc")
122
- await loadData(tableName, {
123
- limit: DEFAULT_LIMIT,
124
- offset: 0,
125
- orderBy: undefined,
126
- orderDirection: "asc",
127
- filterOverride: {
128
- ...filterState,
129
- column: preferredFilterColumn
130
- }
131
- })
132
- } catch (error) {
133
- toast.error(
134
- error instanceof Error
135
- ? error.message
136
- : `Failed to load schema for table '${tableName}'`
137
- )
138
- } finally {
139
- setIsLoadingSchema(false)
140
- }
141
- }
142
-
143
- async function loadData(
144
- tableName: string,
145
- overrides?: {
146
- limit?: number
147
- offset?: number
148
- orderBy?: string
149
- orderDirection?: "asc" | "desc"
150
- filterOverride?: FilterState
151
- }
152
- ) {
153
- setIsLoadingData(true)
154
- const nextLimit = overrides?.limit ?? limit
155
- const nextOffset = overrides?.offset ?? offset
156
- const nextOrderBy = overrides?.orderBy ?? orderBy
157
- const nextOrderDirection = overrides?.orderDirection ?? orderDirection
158
- const nextFilter = overrides?.filterOverride ?? filterState
159
-
160
- try {
161
- const params = new URLSearchParams({
162
- table: tableName,
163
- limit: String(nextLimit),
164
- offset: String(nextOffset)
165
- })
166
-
167
- if (nextOrderBy) {
168
- params.set("orderBy", nextOrderBy)
169
- params.set("orderDirection", nextOrderDirection)
170
- }
171
-
172
- if (nextFilter.column && nextFilter.value.trim().length) {
173
- const key =
174
- nextFilter.operator === "eq"
175
- ? `filter[${nextFilter.column}]`
176
- : `filter[${nextFilter.column}][${nextFilter.operator}]`
177
- params.set(key, nextFilter.value)
178
- } else if (nextFilter.operator === "is" && nextFilter.column) {
179
- const key = `filter[${nextFilter.column}][is]`
180
- params.set(key, nextFilter.value || "null")
181
- }
182
-
183
- const response = await fetch(`/hazo_connect/api/sqlite/data?${params.toString()}`)
184
- if (!response.ok) {
185
- const contentType = response.headers.get("content-type");
186
- if (contentType && contentType.includes("application/json")) {
187
- const errorData = await response.json();
188
- throw new Error(errorData.error || `Failed to load data: ${response.statusText}`);
189
- } else {
190
- const errorText = await response.text();
191
- throw new Error(`Failed to load data: ${errorText.substring(0, 200)}`);
192
- }
193
- }
194
-
195
- const contentType = response.headers.get("content-type");
196
- if (!contentType || !contentType.includes("application/json")) {
197
- const text = await response.text();
198
- throw new Error(`Expected JSON but received ${contentType || "unknown content type"}. Response: ${text.substring(0, 200)}`);
199
- }
200
-
201
- const json = (await response.json()) as DataResponse
202
- setRows(json.data ?? [])
203
- setTotalRows(json.total ?? 0)
204
- setLimit(nextLimit)
205
- setOffset(nextOffset)
206
- setOrderBy(nextOrderBy)
207
- setOrderDirection(nextOrderDirection)
208
- setFilterState(nextFilter)
209
- } catch (error) {
210
- toast.error(
211
- error instanceof Error
212
- ? error.message
213
- : `Failed to load data for table '${tableName}'`
214
- )
215
- } finally {
216
- setIsLoadingData(false)
217
- }
218
- }
219
-
220
- async function refreshTables() {
221
- setIsRefreshingTables(true)
222
- try {
223
- const response = await fetch("/hazo_connect/api/sqlite/tables")
224
- if (!response.ok) {
225
- const contentType = response.headers.get("content-type");
226
- if (contentType && contentType.includes("application/json")) {
227
- const errorData = await response.json();
228
- throw new Error(errorData.error || `Failed to refresh tables: ${response.statusText}`);
229
- } else {
230
- const errorText = await response.text();
231
- throw new Error(`Failed to refresh tables: ${errorText.substring(0, 200)}`);
232
- }
233
- }
234
- const contentType = response.headers.get("content-type");
235
- if (!contentType || !contentType.includes("application/json")) {
236
- const text = await response.text();
237
- throw new Error(`Expected JSON but received ${contentType || "unknown content type"}. Response: ${text.substring(0, 200)}`);
238
- }
239
- const json = await response.json()
240
- setTables(json.data ?? [])
241
- toast.success("Tables refreshed")
242
- } catch (error) {
243
- toast.error(
244
- error instanceof Error ? error.message : "Failed to refresh tables"
245
- )
246
- } finally {
247
- setIsRefreshingTables(false)
248
- }
249
- }
250
-
251
- function handleSelectTable(table: TableSummary) {
252
- setSelectedTable(table)
253
- }
254
-
255
- function handleChangePage(nextOffset: number) {
256
- if (!selectedTable) {
257
- return
258
- }
259
- void loadData(selectedTable.name, { offset: Math.max(0, nextOffset) })
260
- }
261
-
262
- function handleChangeLimit(nextLimit: number) {
263
- if (!selectedTable) {
264
- return
265
- }
266
- void loadData(selectedTable.name, { limit: nextLimit, offset: 0 })
267
- }
268
-
269
- function handleChangeOrder(column?: string) {
270
- if (!selectedTable) {
271
- return
272
- }
273
- const nextDirection =
274
- orderBy === column
275
- ? orderDirection === "asc"
276
- ? "desc"
277
- : "asc"
278
- : "asc"
279
- void loadData(selectedTable.name, {
280
- orderBy: column,
281
- orderDirection: nextDirection
282
- })
283
- }
284
-
285
- function handleApplyFilter() {
286
- if (!selectedTable) {
287
- return
288
- }
289
- void loadData(selectedTable.name, { offset: 0 })
290
- }
291
-
292
- async function handleInsertRow(data: Record<string, unknown>) {
293
- if (!selectedTable) {
294
- return
295
- }
296
-
297
- try {
298
- const response = await fetch("/hazo_connect/api/sqlite/data", {
299
- method: "POST",
300
- headers: {
301
- "Content-Type": "application/json"
302
- },
303
- body: JSON.stringify({ table: selectedTable.name, data })
304
- })
305
-
306
- if (!response.ok) {
307
- throw new Error(await response.text())
308
- }
309
-
310
- toast.success("Row inserted")
311
- setIsCreateOpen(false)
312
- await Promise.all([
313
- refreshTables(),
314
- loadData(selectedTable.name, { offset: 0 })
315
- ])
316
- } catch (error) {
317
- toast.error(error instanceof Error ? error.message : "Insert failed")
318
- }
319
- }
320
-
321
- async function handleUpdateRow(
322
- row: SqlValue,
323
- data: Record<string, unknown>
324
- ) {
325
- if (!selectedTable || !schema) {
326
- return
327
- }
328
-
329
- const criteria = buildCriteriaFromRow(row, schema.columns)
330
- try {
331
- const response = await fetch("/hazo_connect/api/sqlite/data", {
332
- method: "PATCH",
333
- headers: {
334
- "Content-Type": "application/json"
335
- },
336
- body: JSON.stringify({
337
- table: selectedTable.name,
338
- data,
339
- criteria
340
- })
341
- })
342
-
343
- if (!response.ok) {
344
- throw new Error(await response.text())
345
- }
346
-
347
- toast.success("Row updated")
348
- setEditingRow(null)
349
- await loadData(selectedTable.name)
350
- } catch (error) {
351
- toast.error(error instanceof Error ? error.message : "Update failed")
352
- }
353
- }
354
-
355
- async function handleDeleteRow(row: SqlValue) {
356
- if (!selectedTable || !schema) {
357
- return
358
- }
359
-
360
- const criteria = buildCriteriaFromRow(row, schema.columns)
361
- if (!Object.keys(criteria).length) {
362
- toast.error("Unable to determine primary key for row deletion")
363
- return
364
- }
365
-
366
- if (!window.confirm("Delete this row? This action cannot be undone.")) {
367
- return
368
- }
369
-
370
- try {
371
- const response = await fetch("/hazo_connect/api/sqlite/data", {
372
- method: "DELETE",
373
- headers: {
374
- "Content-Type": "application/json"
375
- },
376
- body: JSON.stringify({
377
- table: selectedTable.name,
378
- criteria
379
- })
380
- })
381
-
382
- if (!response.ok) {
383
- throw new Error(await response.text())
384
- }
385
-
386
- toast.success("Row deleted")
387
- await loadData(selectedTable.name, { offset: 0 })
388
- await refreshTables()
389
- } catch (error) {
390
- toast.error(error instanceof Error ? error.message : "Delete failed")
391
- }
392
- }
393
-
394
- const totalPages = Math.max(1, Math.ceil(totalRows / limit))
395
- const currentPage = Math.min(totalPages, Math.floor(offset / limit) + 1)
396
-
397
- return (
398
- <div className="flex min-h-[calc(100vh-4rem)] flex-col gap-4 p-6 lg:flex-row">
399
- <aside className="w-full max-w-xs rounded-lg border border-slate-200 bg-white shadow-sm lg:sticky lg:top-6 lg:h-[calc(100vh-6rem)]">
400
- <header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
401
- <h2 className="text-sm font-medium text-slate-700">Tables</h2>
402
- <button
403
- type="button"
404
- onClick={refreshTables}
405
- disabled={isRefreshingTables}
406
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
407
- >
408
- {isRefreshingTables ? (
409
- <Loader2 className="h-3 w-3 animate-spin" />
410
- ) : (
411
- <RefreshCw className="h-3 w-3" />
412
- )}
413
- Refresh
414
- </button>
415
- </header>
416
- <nav className="max-h-[calc(100vh-9rem)] overflow-auto px-2 py-3 text-sm">
417
- {tables.length === 0 ? (
418
- <p className="rounded-md bg-slate-50 p-3 text-slate-500">
419
- No tables detected.
420
- </p>
421
- ) : (
422
- <ul className="space-y-1">
423
- {tables.map(table => {
424
- const isActive = selectedTable?.name === table.name
425
- return (
426
- <li key={table.name}>
427
- <button
428
- type="button"
429
- onClick={() => handleSelectTable(table)}
430
- className={[
431
- "flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition",
432
- isActive
433
- ? "bg-slate-900 text-white shadow-sm"
434
- : "bg-white text-slate-700 hover:bg-slate-100"
435
- ].join(" ")}
436
- >
437
- <span className="truncate font-medium">{table.name}</span>
438
- {typeof table.row_count === "number" && (
439
- <span
440
- className={[
441
- "ml-2 inline-flex min-w-[2rem] items-center justify-center rounded-full px-2 text-xs",
442
- isActive
443
- ? "bg-slate-800 text-slate-100"
444
- : "bg-slate-100 text-slate-600"
445
- ].join(" ")}
446
- >
447
- {table.row_count}
448
- </span>
449
- )}
450
- </button>
451
- </li>
452
- )
453
- })}
454
- </ul>
455
- )}
456
- </nav>
457
- </aside>
458
-
459
- <section className="flex-1 space-y-6">
460
- <header className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
461
- <div className="flex flex-wrap items-center justify-between gap-3">
462
- <div>
463
- <h1 className="text-xl font-semibold text-slate-900">
464
- SQLite Admin
465
- </h1>
466
- <p className="text-sm text-slate-500">
467
- Browse tables, inspect schema, and edit data safely.
468
- </p>
469
- </div>
470
- <div className="flex items-center gap-2">
471
- <button
472
- type="button"
473
- onClick={() => selectedTable && loadSchemaAndData(selectedTable.name)}
474
- disabled={isLoadingSchema || isLoadingData}
475
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
476
- >
477
- {isLoadingSchema || isLoadingData ? (
478
- <Loader2 className="h-4 w-4 animate-spin" />
479
- ) : (
480
- <RefreshCw className="h-4 w-4" />
481
- )}
482
- Refresh
483
- </button>
484
- <button
485
- type="button"
486
- onClick={() => setIsCreateOpen(true)}
487
- disabled={!selectedTable || !schema}
488
- className="inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-400"
489
- >
490
- <Plus className="h-4 w-4" />
491
- Add Row
492
- </button>
493
- </div>
494
- </div>
495
-
496
- <div className="flex flex-wrap items-center gap-3 rounded-md bg-slate-50 p-3 text-sm text-slate-600">
497
- <Filter className="h-4 w-4 text-slate-500" />
498
- <select
499
- className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
500
- value={filterState.column ?? ""}
501
- onChange={event =>
502
- setFilterState(current => ({
503
- ...current,
504
- column: event.target.value || undefined
505
- }))
506
- }
507
- >
508
- {filterColumns.length === 0 ? (
509
- <option value="">No filterable columns</option>
510
- ) : (
511
- filterColumns.map(column => (
512
- <option key={column.name} value={column.name}>
513
- {column.name}
514
- </option>
515
- ))
516
- )}
517
- </select>
518
- <select
519
- className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
520
- value={filterState.operator}
521
- onChange={event =>
522
- setFilterState(current => ({
523
- ...current,
524
- operator: event.target.value as SqliteFilterOperator
525
- }))
526
- }
527
- >
528
- {filterOperators.map(operator => (
529
- <option key={operator.value} value={operator.value}>
530
- {operator.label}
531
- </option>
532
- ))}
533
- </select>
534
- {filterState.operator === "is" ? (
535
- <select
536
- className="rounded-md border border-slate-200 bg-white px-2 py-1 text-sm"
537
- value={filterState.value}
538
- onChange={event =>
539
- setFilterState(current => ({
540
- ...current,
541
- value: event.target.value
542
- }))
543
- }
544
- >
545
- <option value="null">NULL</option>
546
- <option value="not.null">NOT NULL</option>
547
- </select>
548
- ) : (
549
- <input
550
- type="text"
551
- className="flex-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-sm focus:border-slate-400 focus:outline-none"
552
- placeholder="Filter value…"
553
- value={filterState.value}
554
- onChange={event =>
555
- setFilterState(current => ({
556
- ...current,
557
- value: event.target.value
558
- }))
559
- }
560
- />
561
- )}
562
- <button
563
- type="button"
564
- onClick={handleApplyFilter}
565
- disabled={!selectedTable || !schema}
566
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
567
- >
568
- Apply
569
- </button>
570
- </div>
571
- </header>
572
-
573
- {selectedTable ? (
574
- <>
575
- <section className="rounded-lg border border-slate-200 bg-white shadow-sm">
576
- <header className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
577
- <div>
578
- <h2 className="text-base font-semibold text-slate-800">
579
- {selectedTable.name}
580
- </h2>
581
- <p className="text-xs text-slate-500">
582
- {schema?.columns.length ?? 0} columns
583
- </p>
584
- </div>
585
- <div className="flex items-center gap-2">
586
- <label className="flex items-center gap-1 text-xs text-slate-500">
587
- Rows per page
588
- <select
589
- value={limit}
590
- onChange={event =>
591
- handleChangeLimit(Number.parseInt(event.target.value, 10))
592
- }
593
- className="rounded-md border border-slate-200 bg-white px-2 py-1 text-xs shadow-sm"
594
- >
595
- {[10, 20, 50, 100].map(option => (
596
- <option key={option} value={option}>
597
- {option}
598
- </option>
599
- ))}
600
- </select>
601
- </label>
602
- <div className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-600 shadow-sm">
603
- <button
604
- type="button"
605
- onClick={() => handleChangeOrder(orderBy)}
606
- className="inline-flex items-center gap-1"
607
- disabled={!schema}
608
- >
609
- Order:{" "}
610
- {orderBy ? `${orderBy} • ${orderDirection}` : "not set"}
611
- </button>
612
- {schema && schema.columns.length > 0 && (
613
- <select
614
- value={orderBy ?? ""}
615
- onChange={event =>
616
- handleChangeOrder(event.target.value || undefined)
617
- }
618
- className="rounded border border-slate-200 bg-white px-1 py-0.5 text-xs"
619
- >
620
- <option value="">None</option>
621
- {schema.columns.map(column => (
622
- <option key={column.name} value={column.name}>
623
- {column.name}
624
- </option>
625
- ))}
626
- </select>
627
- )}
628
- </div>
629
- </div>
630
- </header>
631
-
632
- <div className="overflow-auto">
633
- <table className="min-w-full divide-y divide-slate-200 text-sm">
634
- <thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
635
- <tr>
636
- {columns.map(column => (
637
- <th key={column.name} className="px-3 py-2 text-left">
638
- {column.name}
639
- </th>
640
- ))}
641
- <th className="px-3 py-2 text-right text-slate-400">Actions</th>
642
- </tr>
643
- </thead>
644
- <tbody className="divide-y divide-slate-100 bg-white text-sm text-slate-700">
645
- {isLoadingData ? (
646
- <tr>
647
- <td colSpan={columns.length + 1} className="px-3 py-8 text-center">
648
- <div className="inline-flex items-center gap-2 text-slate-500">
649
- <Loader2 className="h-4 w-4 animate-spin" />
650
- Loading data…
651
- </div>
652
- </td>
653
- </tr>
654
- ) : rows.length === 0 ? (
655
- <tr>
656
- <td colSpan={columns.length + 1} className="px-3 py-8 text-center text-slate-400">
657
- No rows to display.
658
- </td>
659
- </tr>
660
- ) : (
661
- rows.map((row, index) => (
662
- <tr key={index} className="hover:bg-slate-50/60">
663
- {columns.map(column => (
664
- <td key={column.name} className="max-w-[200px] truncate px-3 py-2">
665
- {formatCellValue(row[column.name])}
666
- </td>
667
- ))}
668
- <td className="px-3 py-2 text-right">
669
- <div className="inline-flex items-center gap-1">
670
- <button
671
- type="button"
672
- onClick={() => setEditingRow(row)}
673
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100"
674
- >
675
- <Pencil className="h-3 w-3" />
676
- Edit
677
- </button>
678
- <button
679
- type="button"
680
- onClick={() => handleDeleteRow(row)}
681
- className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2 py-1 text-xs font-medium text-red-600 transition hover:bg-red-50"
682
- >
683
- <Trash2 className="h-3 w-3" />
684
- Delete
685
- </button>
686
- </div>
687
- </td>
688
- </tr>
689
- ))
690
- )}
691
- </tbody>
692
- </table>
693
- </div>
694
-
695
- <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 px-4 py-3 text-xs text-slate-500">
696
- <span>
697
- Page {currentPage} of {totalPages} • {totalRows} rows
698
- </span>
699
- <div className="flex items-center gap-2">
700
- <button
701
- type="button"
702
- onClick={() => handleChangePage(Math.max(0, offset - limit))}
703
- disabled={offset === 0 || isLoadingData}
704
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
705
- >
706
- Prev
707
- </button>
708
- <button
709
- type="button"
710
- onClick={() => handleChangePage(offset + limit)}
711
- disabled={offset + limit >= totalRows || isLoadingData}
712
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50"
713
- >
714
- Next
715
- </button>
716
- </div>
717
- </footer>
718
- </section>
719
-
720
- <section className="rounded-lg border border-slate-200 bg-white shadow-sm">
721
- <header className="border-b border-slate-100 px-4 py-3">
722
- <h3 className="text-sm font-semibold text-slate-700">Schema</h3>
723
- </header>
724
- <div className="overflow-auto">
725
- <table className="min-w-full divide-y divide-slate-200 text-sm">
726
- <thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
727
- <tr>
728
- <th className="px-3 py-2 text-left">Column</th>
729
- <th className="px-3 py-2 text-left">Type</th>
730
- <th className="px-3 py-2 text-left">Not null</th>
731
- <th className="px-3 py-2 text-left">Default</th>
732
- <th className="px-3 py-2 text-left">Primary key</th>
733
- </tr>
734
- </thead>
735
- <tbody className="divide-y divide-slate-100 bg-white text-sm text-slate-700">
736
- {columns.map(column => (
737
- <tr key={column.name}>
738
- <td className="px-3 py-2 font-medium">{column.name}</td>
739
- <td className="px-3 py-2">{column.type || "TEXT"}</td>
740
- <td className="px-3 py-2">
741
- {column.notnull ? "Yes" : "No"}
742
- </td>
743
- <td className="px-3 py-2">
744
- {column.default_value === null
745
- ? "NULL"
746
- : String(column.default_value)}
747
- </td>
748
- <td className="px-3 py-2">
749
- {column.primary_key_position
750
- ? `Yes (#${column.primary_key_position})`
751
- : "No"}
752
- </td>
753
- </tr>
754
- ))}
755
- </tbody>
756
- </table>
757
- </div>
758
- </section>
759
- </>
760
- ) : (
761
- <p className="rounded-lg border border-dashed border-slate-300 bg-white p-6 text-center text-sm text-slate-500">
762
- Select a table to begin.
763
- </p>
764
- )}
765
- </section>
766
-
767
- {schema && selectedTable && (
768
- <RowModal
769
- title={`Insert into ${selectedTable.name}`}
770
- open={isCreateOpen}
771
- columns={schema.columns}
772
- onClose={() => setIsCreateOpen(false)}
773
- onSubmit={handleInsertRow}
774
- />
775
- )}
776
-
777
- {schema && selectedTable && editingRow && (
778
- <RowModal
779
- title={`Edit row in ${selectedTable.name}`}
780
- open={Boolean(editingRow)}
781
- columns={schema.columns}
782
- initialValues={editingRow}
783
- onClose={() => setEditingRow(null)}
784
- onSubmit={data => handleUpdateRow(editingRow, data)}
785
- primaryKeys={schema.columns.filter(column => column.primary_key_position > 0)}
786
- />
787
- )}
788
- </div>
789
- )
790
- }
791
-
792
- function RowModal({
793
- title,
794
- open,
795
- columns,
796
- primaryKeys,
797
- initialValues,
798
- onClose,
799
- onSubmit
800
- }: {
801
- title: string
802
- open: boolean
803
- columns: TableColumn[]
804
- primaryKeys?: TableColumn[]
805
- initialValues?: SqlValue
806
- onClose: () => void
807
- onSubmit: (data: Record<string, unknown>) => void
808
- }) {
809
- const [formValues, setFormValues] = useState<Record<string, string>>({})
810
-
811
- useEffect(() => {
812
- if (open) {
813
- const nextValues: Record<string, string> = {}
814
- columns.forEach(column => {
815
- const rawValue = initialValues?.[column.name]
816
- nextValues[column.name] =
817
- rawValue === null || rawValue === undefined ? "" : String(rawValue)
818
- })
819
- setFormValues(nextValues)
820
- }
821
- }, [open, columns, initialValues])
822
-
823
- if (!open) {
824
- return null
825
- }
826
-
827
- function handleChange(column: string, value: string) {
828
- setFormValues(current => ({
829
- ...current,
830
- [column]: value
831
- }))
832
- }
833
-
834
- function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
835
- event.preventDefault()
836
- const payload: Record<string, unknown> = {}
837
-
838
- for (const column of columns) {
839
- const value = formValues[column.name]
840
- payload[column.name] = coerceValue(column, value)
841
- }
842
-
843
- onSubmit(payload)
844
- }
845
-
846
- return (
847
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 backdrop-blur">
848
- <div className="relative w-full max-w-2xl rounded-lg border border-slate-200 bg-white p-6 shadow-xl">
849
- <header className="mb-4 flex items-center justify-between">
850
- <div>
851
- <h2 className="text-lg font-semibold text-slate-900">{title}</h2>
852
- {primaryKeys && primaryKeys.length === 0 && (
853
- <p className="mt-1 text-xs text-amber-600">
854
- Warning: table has no primary key defined. Updates/deletes rely on full row
855
- matching.
856
- </p>
857
- )}
858
- </div>
859
- <button
860
- type="button"
861
- onClick={onClose}
862
- className="rounded-full border border-slate-200 p-1 text-slate-500 transition hover:bg-slate-100"
863
- >
864
- <X className="h-4 w-4" />
865
- </button>
866
- </header>
867
-
868
- <form onSubmit={handleSubmit} className="space-y-4">
869
- <div className="grid max-h-[50vh] grid-cols-1 gap-4 overflow-auto pr-2 sm:grid-cols-2">
870
- {columns.map(column => (
871
- <label key={column.name} className="flex flex-col gap-1 text-sm">
872
- <span className="font-medium text-slate-700">
873
- {column.name}
874
- {column.notnull && (
875
- <sup className="ml-1 text-amber-600" title="Required">
876
- *
877
- </sup>
878
- )}
879
- </span>
880
- <input
881
- type="text"
882
- value={formValues[column.name] ?? ""}
883
- onChange={event => handleChange(column.name, event.target.value)}
884
- className="rounded-md border border-slate-200 px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-slate-400 focus:outline-none"
885
- placeholder={
886
- column.default_value === null
887
- ? column.type || "TEXT"
888
- : String(column.default_value)
889
- }
890
- />
891
- <span className="text-xs text-slate-400">
892
- {column.type || "TEXT"}
893
- {column.primary_key_position ? " • Primary key" : ""}
894
- </span>
895
- </label>
896
- ))}
897
- </div>
898
-
899
- <footer className="flex items-center justify-end gap-2">
900
- <button
901
- type="button"
902
- onClick={onClose}
903
- className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100"
904
- >
905
- Cancel
906
- </button>
907
- <button
908
- type="submit"
909
- className="inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-slate-800"
910
- >
911
- <Save className="h-4 w-4" />
912
- Save
913
- </button>
914
- </footer>
915
- </form>
916
- </div>
917
- </div>
918
- )
919
- }
920
-
921
- function buildCriteriaFromRow(row: SqlValue, columns: TableColumn[]) {
922
- const criteria: Record<string, unknown> = {}
923
- const primaryKeys = columns.filter(column => column.primary_key_position > 0)
924
-
925
- if (primaryKeys.length) {
926
- for (const primary of primaryKeys) {
927
- criteria[primary.name] = row[primary.name]
928
- }
929
- return criteria
930
- }
931
-
932
- for (const column of columns) {
933
- criteria[column.name] = row[column.name]
934
- }
935
- return criteria
936
- }
937
-
938
- function coerceValue(column: TableColumn, rawValue: string): unknown {
939
- if (rawValue === "") {
940
- return column.notnull ? "" : null
941
- }
942
-
943
- const normalisedType = (column.type ?? "").toLowerCase()
944
-
945
- if (normalisedType.includes("int")) {
946
- const parsed = Number.parseInt(rawValue, 10)
947
- return Number.isNaN(parsed) ? rawValue : parsed
948
- }
949
-
950
- if (
951
- normalisedType.includes("real") ||
952
- normalisedType.includes("double") ||
953
- normalisedType.includes("float") ||
954
- normalisedType.includes("numeric")
955
- ) {
956
- const parsed = Number.parseFloat(rawValue)
957
- return Number.isNaN(parsed) ? rawValue : parsed
958
- }
959
-
960
- if (normalisedType.includes("bool")) {
961
- return rawValue === "true" || rawValue === "1" ? 1 : 0
962
- }
963
-
964
- return rawValue
965
- }
966
-
967
- function formatCellValue(value: unknown): string {
968
- if (value === null || value === undefined) {
969
- return "NULL"
970
- }
971
- if (typeof value === "object") {
972
- try {
973
- return JSON.stringify(value)
974
- } catch {
975
- return String(value)
976
- }
977
- }
978
- return String(value)
979
- }
980
-
981
- function identifierIsFilterable(name: string): boolean {
982
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
983
- }
984
-