tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
data/lib/tina4/crud.rb CHANGED
@@ -1,692 +1,692 @@
1
- # frozen_string_literal: true
2
- require "json"
3
-
4
- module Tina4
5
- # Crud — Auto-generate a complete HTML CRUD interface from a SQL query or ORM model.
6
- #
7
- # Usage:
8
- # Tina4.get "/admin/users" do |request, response|
9
- # response.html(Tina4::Crud.to_crud(request, {
10
- # sql: "SELECT id, name, email FROM users",
11
- # title: "User Management",
12
- # primary_key: "id"
13
- # }))
14
- # end
15
- #
16
- # Or with an ORM model class:
17
- # Tina4.get "/admin/users" do |request, response|
18
- # response.html(Tina4::Crud.to_crud(request, {
19
- # model: User,
20
- # title: "User Management"
21
- # }))
22
- # end
23
- module Crud
24
- class << self
25
- # Generate a complete CRUD HTML interface: searchable/paginated table
26
- # with create, edit, and delete modals. Also registers the supporting
27
- # REST API routes on first call per table.
28
- #
29
- # @param request [Tina4::Request] the current request
30
- # @param options [Hash] configuration options
31
- # @option options [String] :sql SQL query for listing records
32
- # @option options [Class] :model ORM model class (alternative to :sql)
33
- # @option options [String] :title page title (default: table name)
34
- # @option options [String] :primary_key primary key column (default: "id")
35
- # @option options [String] :prefix API route prefix (default: "/api")
36
- # @option options [Integer] :limit records per page (default: 10)
37
- # @return [String] complete HTML page
38
- def to_crud(request, options = {})
39
- sql = options[:sql]
40
- model = options[:model]
41
- title = options[:title] || "CRUD"
42
- pk = options[:primary_key] || "id"
43
- prefix = options[:prefix] || "/api"
44
- limit = (options[:limit] || 10).to_i
45
-
46
- # Determine table name and columns from SQL or model
47
- if model
48
- table_name = model.table_name.to_s
49
- pk = (model.primary_key_field || :id).to_s
50
- columns = model.field_definitions.keys.map(&:to_s)
51
- elsif sql
52
- table_name = extract_table_name(sql)
53
- columns = extract_columns(sql)
54
- else
55
- raise ArgumentError, "Crud.to_crud requires either :sql or :model option"
56
- end
57
-
58
- # Parse request params for pagination, search, and sorting
59
- query_params = request.respond_to?(:query) ? request.query : {}
60
- page = [(query_params["page"] || 1).to_i, 1].max
61
- search = query_params["search"].to_s.strip
62
- sort_col = query_params["sort"] || pk
63
- sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
64
- offset = (page - 1) * limit
65
-
66
- # Build the data query
67
- if model
68
- records, total = fetch_model_data(model, search: search, sort: sort_col,
69
- sort_dir: sort_dir, limit: limit, offset: offset)
70
- else
71
- records, total = fetch_sql_data(sql, search: search, sort: sort_col,
72
- sort_dir: sort_dir, limit: limit, offset: offset)
73
- end
74
-
75
- total_pages = total > 0 ? (total.to_f / limit).ceil : 1
76
- api_path = "#{prefix}/#{table_name}"
77
-
78
- # Register supporting CRUD API routes (idempotent)
79
- register_crud_routes(model, table_name, pk, prefix) unless crud_routes_registered?(table_name, prefix)
80
-
81
- # Build the HTML
82
- build_crud_html(
83
- title: title, table_name: table_name, pk: pk,
84
- columns: columns, records: records,
85
- page: page, total_pages: total_pages, total: total,
86
- limit: limit, search: search, sort_col: sort_col,
87
- sort_dir: sort_dir, api_path: api_path,
88
- request_path: request.path
89
- )
90
- end
91
-
92
- # Generate an HTML table from an array of record hashes.
93
- def generate_table(records, table_name: "data", primary_key: "id", editable: true)
94
- return "<p>No records found.</p>" if records.nil? || records.empty?
95
-
96
- columns = records.first.keys
97
-
98
- html = <<~HTML
99
- <div class="table-responsive">
100
- <table class="table table-striped table-hover" id="crud-#{table_name}">
101
- <thead class="table-dark"><tr>
102
- HTML
103
-
104
- columns.each do |col|
105
- html += "<th>#{col}</th>"
106
- end
107
- html += "<th>Actions</th>" if editable
108
- html += "</tr></thead><tbody>"
109
-
110
- records.each do |row|
111
- pk_value = row[primary_key.to_sym] || row[primary_key.to_s]
112
- html += "<tr data-id=\"#{pk_value}\">"
113
- columns.each do |col|
114
- value = row[col]
115
- if editable
116
- html += "<td contenteditable=\"true\" data-field=\"#{col}\">#{value}</td>"
117
- else
118
- html += "<td>#{value}</td>"
119
- end
120
- end
121
- if editable
122
- html += "<td>"
123
- html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudSave('#{table_name}', '#{pk_value}')\">Save</button>"
124
- html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudDelete('#{table_name}', '#{pk_value}')\">Delete</button>"
125
- html += "</td>"
126
- end
127
- html += "</tr>"
128
- end
129
-
130
- html += "</tbody></table></div>"
131
-
132
- if editable
133
- html += inline_crud_javascript(table_name)
134
- end
135
-
136
- html
137
- end
138
-
139
- # Generate an HTML form from a field definition array.
140
- def generate_form(fields, action: "/", method: "POST", table_name: "data")
141
- html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
142
- html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
143
-
144
- fields.each do |field|
145
- name = field[:name]
146
- type = field[:type] || :string
147
- label = field[:label] || name.to_s.capitalize
148
- value = field[:value] || ""
149
- required = field[:required] || false
150
-
151
- html += "<div class=\"mb-3\">"
152
- html += "<label for=\"#{name}\" class=\"form-label\">#{label}</label>"
153
-
154
- case type.to_sym
155
- when :text
156
- html += "<textarea class=\"form-control\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>#{value}</textarea>"
157
- when :boolean
158
- checked = value ? "checked" : ""
159
- html += "<div class=\"form-check\">"
160
- html += "<input class=\"form-check-input\" type=\"checkbox\" id=\"#{name}\" name=\"#{name}\" #{checked}>"
161
- html += "</div>"
162
- when :select
163
- html += "<select class=\"form-select\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>"
164
- (field[:options] || []).each do |opt|
165
- selected = opt[:value].to_s == value.to_s ? "selected" : ""
166
- html += "<option value=\"#{opt[:value]}\" #{selected}>#{opt[:label]}</option>"
167
- end
168
- html += "</select>"
169
- when :date
170
- html += "<input type=\"date\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
171
- when :integer, :number
172
- html += "<input type=\"number\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
173
- else
174
- html += "<input type=\"text\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
175
- end
176
- html += "</div>"
177
- end
178
-
179
- html += "<button type=\"submit\" class=\"btn btn-primary\">Submit</button>"
180
- html += "</form>"
181
- html
182
- end
183
-
184
- private
185
-
186
- # Track which tables have had CRUD routes registered
187
- def registered_tables
188
- @registered_tables ||= {}
189
- end
190
-
191
- def crud_routes_registered?(table_name, prefix)
192
- registered_tables["#{prefix}/#{table_name}"]
193
- end
194
-
195
- # Register REST API routes for the CRUD interface when using :sql mode.
196
- # When using :model mode, the caller should use AutoCrud.register instead
197
- # for full ORM-backed routes. These routes provide basic SQL-backed CRUD.
198
- def register_crud_routes(model, table_name, pk, prefix)
199
- api_path = "#{prefix}/#{table_name}"
200
- registered_tables[api_path] = true
201
-
202
- # If we have a model, use AutoCrud for full ORM-backed routes
203
- if model
204
- Tina4::AutoCrud.register(model)
205
- Tina4::AutoCrud.generate_routes(prefix: prefix)
206
- return
207
- end
208
-
209
- db = Tina4.database
210
- return unless db
211
-
212
- # GET list (already handled by the page itself)
213
- # POST create
214
- Tina4::Router.add("POST", api_path, proc { |req, res|
215
- begin
216
- data = req.body_parsed
217
- result = db.insert(table_name, data)
218
- res.json({ data: data, message: "Created" }, status: 201)
219
- rescue => e
220
- res.json({ error: e.message }, status: 500)
221
- end
222
- })
223
-
224
- # PUT update
225
- Tina4::Router.add("PUT", "#{api_path}/{id}", proc { |req, res|
226
- begin
227
- id = req.params["id"]
228
- data = req.body_parsed
229
- db.update(table_name, data, { pk => id })
230
- res.json({ data: data, message: "Updated" })
231
- rescue => e
232
- res.json({ error: e.message }, status: 500)
233
- end
234
- })
235
-
236
- # DELETE
237
- Tina4::Router.add("DELETE", "#{api_path}/{id}", proc { |req, res|
238
- begin
239
- id = req.params["id"]
240
- db.delete(table_name, { pk => id })
241
- res.json({ message: "Deleted" })
242
- rescue => e
243
- res.json({ error: e.message }, status: 500)
244
- end
245
- })
246
- end
247
-
248
- # Fetch data using an ORM model class
249
- def fetch_model_data(model, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
250
- order_by = "#{sort} #{sort_dir.upcase}"
251
-
252
- if search.empty?
253
- records = model.all(limit: limit, offset: offset, order_by: order_by)
254
- total = model.count
255
- else
256
- # Build search across all string/text fields
257
- searchable = model.field_definitions.select { |_, opts|
258
- [:string, :text].include?(opts[:type])
259
- }.keys
260
- if searchable.empty?
261
- records = model.all(limit: limit, offset: offset, order_by: order_by)
262
- total = model.count
263
- else
264
- where_parts = searchable.map { |col| "#{col} LIKE ?" }
265
- where_clause = where_parts.join(" OR ")
266
- params = searchable.map { "%#{search}%" }
267
- records = model.where(where_clause, params)
268
- total = records.length
269
- records = records.slice(offset, limit) || []
270
- end
271
- end
272
-
273
- record_hashes = records.map { |r| r.to_h }
274
- [record_hashes, total]
275
- end
276
-
277
- # Fetch data using a raw SQL query
278
- def fetch_sql_data(sql, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
279
- db = Tina4.database
280
- return [[], 0] unless db
281
-
282
- # Wrap the original SQL for sorting
283
- query = sql.gsub(/ORDER BY .+$/i, "").gsub(/LIMIT .+$/i, "").strip
284
- query += " ORDER BY #{sort} #{sort_dir.upcase}"
285
-
286
- if !search.empty?
287
- # Wrap in a subquery to add search filtering
288
- wrapped = "SELECT * FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_sub WHERE "
289
- columns = extract_columns(sql)
290
- search_parts = columns.map { |col| "CAST(#{col} AS TEXT) LIKE ?" }
291
- wrapped += search_parts.join(" OR ")
292
- wrapped += " ORDER BY #{sort} #{sort_dir.upcase}"
293
- params = columns.map { "%#{search}%" }
294
-
295
- # Get total count
296
- count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
297
- count_result = db.fetch_one(count_sql, params)
298
- total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
299
-
300
- results = db.fetch(wrapped, params, limit: limit, offset: offset)
301
- else
302
- # Get total count
303
- count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt"
304
- count_result = db.fetch_one(count_sql)
305
- total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
306
-
307
- results = db.fetch(query, [], limit: limit, offset: offset)
308
- end
309
-
310
- records = results.respond_to?(:records) ? results.records : results.to_a
311
- [records, total]
312
- end
313
-
314
- # Extract table name from a SQL SELECT statement
315
- def extract_table_name(sql)
316
- match = sql.match(/FROM\s+(\w+)/i)
317
- match ? match[1] : "data"
318
- end
319
-
320
- # Extract column names from a SQL SELECT statement
321
- def extract_columns(sql)
322
- match = sql.match(/SELECT\s+(.+?)\s+FROM/im)
323
- return ["*"] unless match
324
-
325
- cols_str = match[1].strip
326
- return ["*"] if cols_str == "*"
327
-
328
- cols_str.split(",").map { |c|
329
- c = c.strip
330
- # Handle "table.column AS alias" or "column AS alias"
331
- if c =~ /\bAS\s+(\w+)/i
332
- $1
333
- elsif c.include?(".")
334
- c.split(".").last.strip
335
- else
336
- c.strip
337
- end
338
- }
339
- end
340
-
341
- # Escape HTML special characters
342
- def h(text)
343
- text.to_s
344
- .gsub("&", "&amp;")
345
- .gsub("<", "&lt;")
346
- .gsub(">", "&gt;")
347
- .gsub('"', "&quot;")
348
- .gsub("'", "&#39;")
349
- end
350
-
351
- # Pretty label from a column name: "user_name" => "User Name"
352
- def pretty_label(col)
353
- col.to_s.split("_").map(&:capitalize).join(" ")
354
- end
355
-
356
- # Determine input type from column name or ORM field type
357
- def input_type_for(col, model = nil)
358
- if model && model.respond_to?(:field_definitions)
359
- opts = model.field_definitions[col.to_sym]
360
- if opts
361
- case opts[:type]
362
- when :integer then return "number"
363
- when :float, :decimal then return "number"
364
- when :boolean then return "checkbox"
365
- when :date then return "date"
366
- when :datetime, :timestamp then return "datetime-local"
367
- when :text then return "textarea"
368
- end
369
- end
370
- end
371
- # Guess from column name
372
- return "email" if col.to_s.include?("email")
373
- return "date" if col.to_s.end_with?("_at", "_date")
374
- return "number" if col.to_s.end_with?("_id") && col.to_s != "id"
375
- "text"
376
- end
377
-
378
- # Build the complete CRUD HTML page
379
- def build_crud_html(title:, table_name:, pk:, columns:, records:,
380
- page:, total_pages:, total:, limit:, search:,
381
- sort_col:, sort_dir:, api_path:, request_path:)
382
- # Filter out auto-increment PK from editable columns
383
- editable_columns = columns.reject { |c| c.to_s == pk.to_s }
384
-
385
- html = <<~HTML
386
- <!DOCTYPE html>
387
- <html lang="en">
388
- <head>
389
- <meta charset="UTF-8">
390
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
391
- <title>#{h(title)}</title>
392
- <link rel="stylesheet" href="/css/tina4.min.css">
393
- <style>
394
- .crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
395
- .crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
396
- .crud-search { max-width: 300px; }
397
- .crud-actions { display: flex; gap: 0.5rem; align-items: center; }
398
- .crud-info { color: var(--text-muted, #6c757d); font-size: 0.875rem; margin-bottom: 0.5rem; }
399
- .crud-pagination { display: flex; justify-content: center; gap: 0.25rem; margin-top: 1rem; }
400
- .sort-link { text-decoration: none; color: inherit; cursor: pointer; }
401
- .sort-link:hover { text-decoration: underline; }
402
- .sort-indicator { font-size: 0.75rem; }
403
- .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
404
- background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
405
- .modal-overlay.active { display: flex; }
406
- .modal-box { background: var(--bg, #fff); border-radius: 0.5rem; padding: 1.5rem;
407
- width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto;
408
- box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
409
- .modal-box h3 { margin-top: 0; }
410
- .modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
411
- .alert { padding: 0.75rem 1rem; border-radius: 0.25rem; margin-bottom: 1rem; display: none; }
412
- .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
413
- .alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
414
- </style>
415
- </head>
416
- <body>
417
- <div class="crud-container">
418
- <div id="crud-alert" class="alert"></div>
419
- <div class="crud-header">
420
- <h2>#{h(title)}</h2>
421
- <div class="crud-actions">
422
- <form method="GET" action="#{h(request_path)}" style="display:flex;gap:0.5rem;">
423
- <input type="text" name="search" value="#{h(search)}" placeholder="Search..."
424
- class="form-control crud-search">
425
- <button type="submit" class="btn btn-secondary">Search</button>
426
- </form>
427
- <button class="btn btn-primary" onclick="crudShowCreate()">+ New</button>
428
- </div>
429
- </div>
430
- <div class="crud-info">
431
- Showing #{records.length} of #{total} records (page #{page} of #{total_pages})
432
- </div>
433
- <div class="table-responsive">
434
- <table class="table table-striped table-hover">
435
- <thead class="table-dark"><tr>
436
- HTML
437
-
438
- # Table headers with sort links
439
- columns.each do |col|
440
- next_dir = (sort_col == col.to_s && sort_dir == "asc") ? "desc" : "asc"
441
- indicator = ""
442
- if sort_col == col.to_s
443
- indicator = sort_dir == "asc" ? " <span class=\"sort-indicator\">&#9650;</span>" : " <span class=\"sort-indicator\">&#9660;</span>"
444
- end
445
- sort_params = "sort=#{h(col)}&sort_dir=#{next_dir}&page=#{page}&search=#{URI.encode_www_form_component(search)}&limit=#{limit}"
446
- html += "<th><a class=\"sort-link\" href=\"#{h(request_path)}?#{sort_params}\">#{pretty_label(col)}#{indicator}</a></th>"
447
- end
448
- html += "<th>Actions</th></tr></thead><tbody>"
449
-
450
- # Table body
451
- if records.empty?
452
- html += "<tr><td colspan=\"#{columns.length + 1}\" style=\"text-align:center;padding:2rem;\">No records found.</td></tr>"
453
- else
454
- records.each do |row|
455
- pk_value = row[pk.to_sym] || row[pk.to_s] || row[pk]
456
- html += "<tr>"
457
- columns.each do |col|
458
- value = row[col.to_sym] || row[col.to_s] || row[col]
459
- html += "<td>#{h(value)}</td>"
460
- end
461
- html += "<td>"
462
- html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudShowEdit(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Edit</button>"
463
- html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudShowDelete(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Delete</button>"
464
- html += "</td></tr>"
465
- end
466
- end
467
- html += "</tbody></table></div>"
468
-
469
- # Pagination
470
- if total_pages > 1
471
- html += "<div class=\"crud-pagination\">"
472
- if page > 1
473
- html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page - 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Prev</a>"
474
- end
475
- # Show page numbers (max 7)
476
- start_page = [page - 3, 1].max
477
- end_page = [start_page + 6, total_pages].min
478
- start_page = [end_page - 6, 1].max
479
- (start_page..end_page).each do |p|
480
- active = p == page ? " btn-primary" : " btn-secondary"
481
- html += "<a class=\"btn btn-sm#{active}\" href=\"#{h(request_path)}?page=#{p}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">#{p}</a>"
482
- end
483
- if page < total_pages
484
- html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page + 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Next</a>"
485
- end
486
- html += "</div>"
487
- end
488
-
489
- # Create modal
490
- html += build_modal("create", "Create New Record", editable_columns, pk, api_path, request_path)
491
-
492
- # Edit modal
493
- html += build_modal("edit", "Edit Record", editable_columns, pk, api_path, request_path, edit: true)
494
-
495
- # Delete confirmation modal
496
- html += <<~HTML
497
- <div class="modal-overlay" id="modal-delete">
498
- <div class="modal-box">
499
- <h3>Confirm Delete</h3>
500
- <p>Are you sure you want to delete this record? This action cannot be undone.</p>
501
- <input type="hidden" id="delete-pk-value">
502
- <div class="modal-footer">
503
- <button class="btn btn-secondary" onclick="crudCloseModal('delete')">Cancel</button>
504
- <button class="btn btn-danger" onclick="crudConfirmDelete()">Delete</button>
505
- </div>
506
- </div>
507
- </div>
508
- HTML
509
-
510
- # JavaScript
511
- html += build_crud_javascript(api_path, pk, editable_columns, request_path)
512
-
513
- html += "</div></body></html>"
514
- html
515
- end
516
-
517
- # Build a create or edit modal
518
- def build_modal(id, title, columns, pk, api_path, request_path, edit: false)
519
- html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
520
- html += "<div class=\"modal-box\">"
521
- html += "<h3>#{h(title)}</h3>"
522
- html += "<form id=\"form-#{id}\" onsubmit=\"return false;\">"
523
- html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
524
-
525
- columns.each do |col|
526
- label = pretty_label(col)
527
- field_id = "#{id}-#{col}"
528
- html += "<div class=\"mb-3\">"
529
- html += "<label for=\"#{field_id}\" class=\"form-label\">#{label}</label>"
530
- html += "<input type=\"text\" class=\"form-control\" id=\"#{field_id}\" name=\"#{col}\" placeholder=\"Enter #{label.downcase}\">"
531
- html += "</div>"
532
- end
533
-
534
- html += "<div class=\"modal-footer\">"
535
- html += "<button type=\"button\" class=\"btn btn-secondary\" onclick=\"crudCloseModal('#{id}')\">Cancel</button>"
536
- html += "<button type=\"button\" class=\"btn btn-primary\" onclick=\"crudSave#{edit ? 'Edit' : 'Create'}()\">Save</button>"
537
- html += "</div></form></div></div>"
538
- html
539
- end
540
-
541
- # Build the JavaScript for the CRUD interface
542
- def build_crud_javascript(api_path, pk, columns, request_path)
543
- columns_json = JSON.generate(columns.map(&:to_s))
544
- <<~HTML
545
- <script>
546
- var CRUD_API = '#{api_path}';
547
- var CRUD_PK = '#{pk}';
548
- var CRUD_COLUMNS = #{columns_json};
549
-
550
- function crudShowAlert(message, type) {
551
- var el = document.getElementById('crud-alert');
552
- el.className = 'alert alert-' + type;
553
- el.textContent = message;
554
- el.style.display = 'block';
555
- setTimeout(function() { el.style.display = 'none'; }, 3000);
556
- }
557
-
558
- function crudShowCreate() {
559
- var form = document.getElementById('form-create');
560
- form.reset();
561
- document.getElementById('modal-create').classList.add('active');
562
- }
563
-
564
- function crudShowEdit(id) {
565
- fetch(CRUD_API + '/' + id)
566
- .then(function(r) { return r.json(); })
567
- .then(function(result) {
568
- var data = result.data || result;
569
- document.getElementById('edit-pk-value').value = id;
570
- CRUD_COLUMNS.forEach(function(col) {
571
- var input = document.getElementById('edit-' + col);
572
- if (input) input.value = data[col] != null ? data[col] : '';
573
- });
574
- document.getElementById('modal-edit').classList.add('active');
575
- })
576
- .catch(function(e) { crudShowAlert('Failed to load record: ' + e, 'danger'); });
577
- }
578
-
579
- function crudShowDelete(id) {
580
- document.getElementById('delete-pk-value').value = id;
581
- document.getElementById('modal-delete').classList.add('active');
582
- }
583
-
584
- function crudCloseModal(name) {
585
- document.getElementById('modal-' + name).classList.remove('active');
586
- }
587
-
588
- function crudSaveCreate() {
589
- var data = {};
590
- CRUD_COLUMNS.forEach(function(col) {
591
- var input = document.getElementById('create-' + col);
592
- if (input) data[col] = input.value;
593
- });
594
- fetch(CRUD_API, {
595
- method: 'POST',
596
- headers: { 'Content-Type': 'application/json' },
597
- body: JSON.stringify(data)
598
- })
599
- .then(function(r) { return r.json(); })
600
- .then(function(result) {
601
- if (result.error) { crudShowAlert(result.error, 'danger'); return; }
602
- crudCloseModal('create');
603
- crudShowAlert('Record created successfully', 'success');
604
- setTimeout(function() { window.location.reload(); }, 500);
605
- })
606
- .catch(function(e) { crudShowAlert('Failed to create: ' + e, 'danger'); });
607
- }
608
-
609
- function crudSaveEdit() {
610
- var id = document.getElementById('edit-pk-value').value;
611
- var data = {};
612
- CRUD_COLUMNS.forEach(function(col) {
613
- var input = document.getElementById('edit-' + col);
614
- if (input) data[col] = input.value;
615
- });
616
- fetch(CRUD_API + '/' + id, {
617
- method: 'PUT',
618
- headers: { 'Content-Type': 'application/json' },
619
- body: JSON.stringify(data)
620
- })
621
- .then(function(r) { return r.json(); })
622
- .then(function(result) {
623
- if (result.error) { crudShowAlert(result.error, 'danger'); return; }
624
- crudCloseModal('edit');
625
- crudShowAlert('Record updated successfully', 'success');
626
- setTimeout(function() { window.location.reload(); }, 500);
627
- })
628
- .catch(function(e) { crudShowAlert('Failed to update: ' + e, 'danger'); });
629
- }
630
-
631
- function crudConfirmDelete() {
632
- var id = document.getElementById('delete-pk-value').value;
633
- fetch(CRUD_API + '/' + id, { method: 'DELETE' })
634
- .then(function(r) { return r.json(); })
635
- .then(function(result) {
636
- if (result.error) { crudShowAlert(result.error, 'danger'); return; }
637
- crudCloseModal('delete');
638
- crudShowAlert('Record deleted successfully', 'success');
639
- setTimeout(function() { window.location.reload(); }, 500);
640
- })
641
- .catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
642
- }
643
-
644
- // Close modal on overlay click
645
- document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
646
- overlay.addEventListener('click', function(e) {
647
- if (e.target === overlay) overlay.classList.remove('active');
648
- });
649
- });
650
-
651
- // Close modal on Escape key
652
- document.addEventListener('keydown', function(e) {
653
- if (e.key === 'Escape') {
654
- document.querySelectorAll('.modal-overlay.active').forEach(function(m) {
655
- m.classList.remove('active');
656
- });
657
- }
658
- });
659
- </script>
660
- HTML
661
- end
662
-
663
- def inline_crud_javascript(table_name)
664
- <<~JS
665
- <script>
666
- function crudSave(table, id) {
667
- const row = document.querySelector(`tr[data-id="${id}"]`);
668
- const cells = row.querySelectorAll('td[data-field]');
669
- const data = {};
670
- cells.forEach(cell => { data[cell.dataset.field] = cell.textContent; });
671
- fetch(`/api/${table}/${id}`, {
672
- method: 'PUT',
673
- headers: { 'Content-Type': 'application/json' },
674
- body: JSON.stringify(data)
675
- }).then(r => r.json()).then(d => { alert('Saved!'); }).catch(e => alert('Error: ' + e));
676
- }
677
- function crudDelete(table, id) {
678
- if (!confirm('Delete this record?')) return;
679
- fetch(`/api/${table}/${id}`, { method: 'DELETE' })
680
- .then(r => r.json())
681
- .then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
682
- .catch(e => alert('Error: ' + e));
683
- }
684
- </script>
685
- JS
686
- end
687
- end
688
- end
689
-
690
- # Uppercase alias for convenience: Tina4::CRUD.to_crud(...)
691
- CRUD = Crud
692
- end
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Tina4
5
+ # Crud — Auto-generate a complete HTML CRUD interface from a SQL query or ORM model.
6
+ #
7
+ # Usage:
8
+ # Tina4.get "/admin/users" do |request, response|
9
+ # response.html(Tina4::Crud.to_crud(request, {
10
+ # sql: "SELECT id, name, email FROM users",
11
+ # title: "User Management",
12
+ # primary_key: "id"
13
+ # }))
14
+ # end
15
+ #
16
+ # Or with an ORM model class:
17
+ # Tina4.get "/admin/users" do |request, response|
18
+ # response.html(Tina4::Crud.to_crud(request, {
19
+ # model: User,
20
+ # title: "User Management"
21
+ # }))
22
+ # end
23
+ module Crud
24
+ class << self
25
+ # Generate a complete CRUD HTML interface: searchable/paginated table
26
+ # with create, edit, and delete modals. Also registers the supporting
27
+ # REST API routes on first call per table.
28
+ #
29
+ # @param request [Tina4::Request] the current request
30
+ # @param options [Hash] configuration options
31
+ # @option options [String] :sql SQL query for listing records
32
+ # @option options [Class] :model ORM model class (alternative to :sql)
33
+ # @option options [String] :title page title (default: table name)
34
+ # @option options [String] :primary_key primary key column (default: "id")
35
+ # @option options [String] :prefix API route prefix (default: "/api")
36
+ # @option options [Integer] :limit records per page (default: 10)
37
+ # @return [String] complete HTML page
38
+ def to_crud(request, options = {})
39
+ sql = options[:sql]
40
+ model = options[:model]
41
+ title = options[:title] || "CRUD"
42
+ pk = options[:primary_key] || "id"
43
+ prefix = options[:prefix] || "/api"
44
+ limit = (options[:limit] || 10).to_i
45
+
46
+ # Determine table name and columns from SQL or model
47
+ if model
48
+ table_name = model.table_name.to_s
49
+ pk = (model.primary_key_field || :id).to_s
50
+ columns = model.field_definitions.keys.map(&:to_s)
51
+ elsif sql
52
+ table_name = extract_table_name(sql)
53
+ columns = extract_columns(sql)
54
+ else
55
+ raise ArgumentError, "Crud.to_crud requires either :sql or :model option"
56
+ end
57
+
58
+ # Parse request params for pagination, search, and sorting
59
+ query_params = request.respond_to?(:query) ? request.query : {}
60
+ page = [(query_params["page"] || 1).to_i, 1].max
61
+ search = query_params["search"].to_s.strip
62
+ sort_col = query_params["sort"] || pk
63
+ sort_dir = query_params["sort_dir"] == "desc" ? "desc" : "asc"
64
+ offset = (page - 1) * limit
65
+
66
+ # Build the data query
67
+ if model
68
+ records, total = fetch_model_data(model, search: search, sort: sort_col,
69
+ sort_dir: sort_dir, limit: limit, offset: offset)
70
+ else
71
+ records, total = fetch_sql_data(sql, search: search, sort: sort_col,
72
+ sort_dir: sort_dir, limit: limit, offset: offset)
73
+ end
74
+
75
+ total_pages = total > 0 ? (total.to_f / limit).ceil : 1
76
+ api_path = "#{prefix}/#{table_name}"
77
+
78
+ # Register supporting CRUD API routes (idempotent)
79
+ register_crud_routes(model, table_name, pk, prefix) unless crud_routes_registered?(table_name, prefix)
80
+
81
+ # Build the HTML
82
+ build_crud_html(
83
+ title: title, table_name: table_name, pk: pk,
84
+ columns: columns, records: records,
85
+ page: page, total_pages: total_pages, total: total,
86
+ limit: limit, search: search, sort_col: sort_col,
87
+ sort_dir: sort_dir, api_path: api_path,
88
+ request_path: request.path
89
+ )
90
+ end
91
+
92
+ # Generate an HTML table from an array of record hashes.
93
+ def generate_table(records, table_name: "data", primary_key: "id", editable: true)
94
+ return "<p>No records found.</p>" if records.nil? || records.empty?
95
+
96
+ columns = records.first.keys
97
+
98
+ html = <<~HTML
99
+ <div class="table-responsive">
100
+ <table class="table table-striped table-hover" id="crud-#{table_name}">
101
+ <thead class="table-dark"><tr>
102
+ HTML
103
+
104
+ columns.each do |col|
105
+ html += "<th>#{col}</th>"
106
+ end
107
+ html += "<th>Actions</th>" if editable
108
+ html += "</tr></thead><tbody>"
109
+
110
+ records.each do |row|
111
+ pk_value = row[primary_key.to_sym] || row[primary_key.to_s]
112
+ html += "<tr data-id=\"#{pk_value}\">"
113
+ columns.each do |col|
114
+ value = row[col]
115
+ if editable
116
+ html += "<td contenteditable=\"true\" data-field=\"#{col}\">#{value}</td>"
117
+ else
118
+ html += "<td>#{value}</td>"
119
+ end
120
+ end
121
+ if editable
122
+ html += "<td>"
123
+ html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudSave('#{table_name}', '#{pk_value}')\">Save</button>"
124
+ html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudDelete('#{table_name}', '#{pk_value}')\">Delete</button>"
125
+ html += "</td>"
126
+ end
127
+ html += "</tr>"
128
+ end
129
+
130
+ html += "</tbody></table></div>"
131
+
132
+ if editable
133
+ html += inline_crud_javascript(table_name)
134
+ end
135
+
136
+ html
137
+ end
138
+
139
+ # Generate an HTML form from a field definition array.
140
+ def generate_form(fields, action: "/", method: "POST", table_name: "data")
141
+ html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
142
+ html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
143
+
144
+ fields.each do |field|
145
+ name = field[:name]
146
+ type = field[:type] || :string
147
+ label = field[:label] || name.to_s.capitalize
148
+ value = field[:value] || ""
149
+ required = field[:required] || false
150
+
151
+ html += "<div class=\"mb-3\">"
152
+ html += "<label for=\"#{name}\" class=\"form-label\">#{label}</label>"
153
+
154
+ case type.to_sym
155
+ when :text
156
+ html += "<textarea class=\"form-control\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>#{value}</textarea>"
157
+ when :boolean
158
+ checked = value ? "checked" : ""
159
+ html += "<div class=\"form-check\">"
160
+ html += "<input class=\"form-check-input\" type=\"checkbox\" id=\"#{name}\" name=\"#{name}\" #{checked}>"
161
+ html += "</div>"
162
+ when :select
163
+ html += "<select class=\"form-select\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>"
164
+ (field[:options] || []).each do |opt|
165
+ selected = opt[:value].to_s == value.to_s ? "selected" : ""
166
+ html += "<option value=\"#{opt[:value]}\" #{selected}>#{opt[:label]}</option>"
167
+ end
168
+ html += "</select>"
169
+ when :date
170
+ html += "<input type=\"date\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
171
+ when :integer, :number
172
+ html += "<input type=\"number\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
173
+ else
174
+ html += "<input type=\"text\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
175
+ end
176
+ html += "</div>"
177
+ end
178
+
179
+ html += "<button type=\"submit\" class=\"btn btn-primary\">Submit</button>"
180
+ html += "</form>"
181
+ html
182
+ end
183
+
184
+ private
185
+
186
+ # Track which tables have had CRUD routes registered
187
+ def registered_tables
188
+ @registered_tables ||= {}
189
+ end
190
+
191
+ def crud_routes_registered?(table_name, prefix)
192
+ registered_tables["#{prefix}/#{table_name}"]
193
+ end
194
+
195
+ # Register REST API routes for the CRUD interface when using :sql mode.
196
+ # When using :model mode, the caller should use AutoCrud.register instead
197
+ # for full ORM-backed routes. These routes provide basic SQL-backed CRUD.
198
+ def register_crud_routes(model, table_name, pk, prefix)
199
+ api_path = "#{prefix}/#{table_name}"
200
+ registered_tables[api_path] = true
201
+
202
+ # If we have a model, use AutoCrud for full ORM-backed routes
203
+ if model
204
+ Tina4::AutoCrud.register(model)
205
+ Tina4::AutoCrud.generate_routes(prefix: prefix)
206
+ return
207
+ end
208
+
209
+ db = Tina4.database
210
+ return unless db
211
+
212
+ # GET list (already handled by the page itself)
213
+ # POST create
214
+ Tina4::Router.add("POST", api_path, proc { |req, res|
215
+ begin
216
+ data = req.body_parsed
217
+ result = db.insert(table_name, data)
218
+ res.json({ data: data, message: "Created" }, status: 201)
219
+ rescue => e
220
+ res.json({ error: e.message }, status: 500)
221
+ end
222
+ })
223
+
224
+ # PUT update
225
+ Tina4::Router.add("PUT", "#{api_path}/{id}", proc { |req, res|
226
+ begin
227
+ id = req.params["id"]
228
+ data = req.body_parsed
229
+ db.update(table_name, data, { pk => id })
230
+ res.json({ data: data, message: "Updated" })
231
+ rescue => e
232
+ res.json({ error: e.message }, status: 500)
233
+ end
234
+ })
235
+
236
+ # DELETE
237
+ Tina4::Router.add("DELETE", "#{api_path}/{id}", proc { |req, res|
238
+ begin
239
+ id = req.params["id"]
240
+ db.delete(table_name, { pk => id })
241
+ res.json({ message: "Deleted" })
242
+ rescue => e
243
+ res.json({ error: e.message }, status: 500)
244
+ end
245
+ })
246
+ end
247
+
248
+ # Fetch data using an ORM model class
249
+ def fetch_model_data(model, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
250
+ order_by = "#{sort} #{sort_dir.upcase}"
251
+
252
+ if search.empty?
253
+ records = model.all(limit: limit, offset: offset, order_by: order_by)
254
+ total = model.count
255
+ else
256
+ # Build search across all string/text fields
257
+ searchable = model.field_definitions.select { |_, opts|
258
+ [:string, :text].include?(opts[:type])
259
+ }.keys
260
+ if searchable.empty?
261
+ records = model.all(limit: limit, offset: offset, order_by: order_by)
262
+ total = model.count
263
+ else
264
+ where_parts = searchable.map { |col| "#{col} LIKE ?" }
265
+ where_clause = where_parts.join(" OR ")
266
+ params = searchable.map { "%#{search}%" }
267
+ records = model.where(where_clause, params)
268
+ total = records.length
269
+ records = records.slice(offset, limit) || []
270
+ end
271
+ end
272
+
273
+ record_hashes = records.map { |r| r.to_h }
274
+ [record_hashes, total]
275
+ end
276
+
277
+ # Fetch data using a raw SQL query
278
+ def fetch_sql_data(sql, search: "", sort: "id", sort_dir: "asc", limit: 10, offset: 0)
279
+ db = Tina4.database
280
+ return [[], 0] unless db
281
+
282
+ # Wrap the original SQL for sorting
283
+ query = sql.gsub(/ORDER BY .+$/i, "").gsub(/LIMIT .+$/i, "").strip
284
+ query += " ORDER BY #{sort} #{sort_dir.upcase}"
285
+
286
+ if !search.empty?
287
+ # Wrap in a subquery to add search filtering
288
+ wrapped = "SELECT * FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_sub WHERE "
289
+ columns = extract_columns(sql)
290
+ search_parts = columns.map { |col| "CAST(#{col} AS TEXT) LIKE ?" }
291
+ wrapped += search_parts.join(" OR ")
292
+ wrapped += " ORDER BY #{sort} #{sort_dir.upcase}"
293
+ params = columns.map { "%#{search}%" }
294
+
295
+ # Get total count
296
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt WHERE #{search_parts.join(' OR ')}"
297
+ count_result = db.fetch_one(count_sql, params)
298
+ total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
299
+
300
+ results = db.fetch(wrapped, params, limit: limit, offset: offset)
301
+ else
302
+ # Get total count
303
+ count_sql = "SELECT COUNT(*) as cnt FROM (#{sql.gsub(/ORDER BY .+$/i, '').gsub(/LIMIT .+$/i, '').strip}) AS _crud_cnt"
304
+ count_result = db.fetch_one(count_sql)
305
+ total = count_result ? (count_result[:cnt] || count_result["cnt"] || 0).to_i : 0
306
+
307
+ results = db.fetch(query, [], limit: limit, offset: offset)
308
+ end
309
+
310
+ records = results.respond_to?(:records) ? results.records : results.to_a
311
+ [records, total]
312
+ end
313
+
314
+ # Extract table name from a SQL SELECT statement
315
+ def extract_table_name(sql)
316
+ match = sql.match(/FROM\s+(\w+)/i)
317
+ match ? match[1] : "data"
318
+ end
319
+
320
+ # Extract column names from a SQL SELECT statement
321
+ def extract_columns(sql)
322
+ match = sql.match(/SELECT\s+(.+?)\s+FROM/im)
323
+ return ["*"] unless match
324
+
325
+ cols_str = match[1].strip
326
+ return ["*"] if cols_str == "*"
327
+
328
+ cols_str.split(",").map { |c|
329
+ c = c.strip
330
+ # Handle "table.column AS alias" or "column AS alias"
331
+ if c =~ /\bAS\s+(\w+)/i
332
+ $1
333
+ elsif c.include?(".")
334
+ c.split(".").last.strip
335
+ else
336
+ c.strip
337
+ end
338
+ }
339
+ end
340
+
341
+ # Escape HTML special characters
342
+ def h(text)
343
+ text.to_s
344
+ .gsub("&", "&amp;")
345
+ .gsub("<", "&lt;")
346
+ .gsub(">", "&gt;")
347
+ .gsub('"', "&quot;")
348
+ .gsub("'", "&#39;")
349
+ end
350
+
351
+ # Pretty label from a column name: "user_name" => "User Name"
352
+ def pretty_label(col)
353
+ col.to_s.split("_").map(&:capitalize).join(" ")
354
+ end
355
+
356
+ # Determine input type from column name or ORM field type
357
+ def input_type_for(col, model = nil)
358
+ if model && model.respond_to?(:field_definitions)
359
+ opts = model.field_definitions[col.to_sym]
360
+ if opts
361
+ case opts[:type]
362
+ when :integer then return "number"
363
+ when :float, :decimal then return "number"
364
+ when :boolean then return "checkbox"
365
+ when :date then return "date"
366
+ when :datetime, :timestamp then return "datetime-local"
367
+ when :text then return "textarea"
368
+ end
369
+ end
370
+ end
371
+ # Guess from column name
372
+ return "email" if col.to_s.include?("email")
373
+ return "date" if col.to_s.end_with?("_at", "_date")
374
+ return "number" if col.to_s.end_with?("_id") && col.to_s != "id"
375
+ "text"
376
+ end
377
+
378
+ # Build the complete CRUD HTML page
379
+ def build_crud_html(title:, table_name:, pk:, columns:, records:,
380
+ page:, total_pages:, total:, limit:, search:,
381
+ sort_col:, sort_dir:, api_path:, request_path:)
382
+ # Filter out auto-increment PK from editable columns
383
+ editable_columns = columns.reject { |c| c.to_s == pk.to_s }
384
+
385
+ html = <<~HTML
386
+ <!DOCTYPE html>
387
+ <html lang="en">
388
+ <head>
389
+ <meta charset="UTF-8">
390
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
391
+ <title>#{h(title)}</title>
392
+ <link rel="stylesheet" href="/css/tina4.min.css">
393
+ <style>
394
+ .crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
395
+ .crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
396
+ .crud-search { max-width: 300px; }
397
+ .crud-actions { display: flex; gap: 0.5rem; align-items: center; }
398
+ .crud-info { color: var(--text-muted, #6c757d); font-size: 0.875rem; margin-bottom: 0.5rem; }
399
+ .crud-pagination { display: flex; justify-content: center; gap: 0.25rem; margin-top: 1rem; }
400
+ .sort-link { text-decoration: none; color: inherit; cursor: pointer; }
401
+ .sort-link:hover { text-decoration: underline; }
402
+ .sort-indicator { font-size: 0.75rem; }
403
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
404
+ background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
405
+ .modal-overlay.active { display: flex; }
406
+ .modal-box { background: var(--bg, #fff); border-radius: 0.5rem; padding: 1.5rem;
407
+ width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto;
408
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
409
+ .modal-box h3 { margin-top: 0; }
410
+ .modal-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
411
+ .alert { padding: 0.75rem 1rem; border-radius: 0.25rem; margin-bottom: 1rem; display: none; }
412
+ .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
413
+ .alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
414
+ </style>
415
+ </head>
416
+ <body>
417
+ <div class="crud-container">
418
+ <div id="crud-alert" class="alert"></div>
419
+ <div class="crud-header">
420
+ <h2>#{h(title)}</h2>
421
+ <div class="crud-actions">
422
+ <form method="GET" action="#{h(request_path)}" style="display:flex;gap:0.5rem;">
423
+ <input type="text" name="search" value="#{h(search)}" placeholder="Search..."
424
+ class="form-control crud-search">
425
+ <button type="submit" class="btn btn-secondary">Search</button>
426
+ </form>
427
+ <button class="btn btn-primary" onclick="crudShowCreate()">+ New</button>
428
+ </div>
429
+ </div>
430
+ <div class="crud-info">
431
+ Showing #{records.length} of #{total} records (page #{page} of #{total_pages})
432
+ </div>
433
+ <div class="table-responsive">
434
+ <table class="table table-striped table-hover">
435
+ <thead class="table-dark"><tr>
436
+ HTML
437
+
438
+ # Table headers with sort links
439
+ columns.each do |col|
440
+ next_dir = (sort_col == col.to_s && sort_dir == "asc") ? "desc" : "asc"
441
+ indicator = ""
442
+ if sort_col == col.to_s
443
+ indicator = sort_dir == "asc" ? " <span class=\"sort-indicator\">&#9650;</span>" : " <span class=\"sort-indicator\">&#9660;</span>"
444
+ end
445
+ sort_params = "sort=#{h(col)}&sort_dir=#{next_dir}&page=#{page}&search=#{URI.encode_www_form_component(search)}&limit=#{limit}"
446
+ html += "<th><a class=\"sort-link\" href=\"#{h(request_path)}?#{sort_params}\">#{pretty_label(col)}#{indicator}</a></th>"
447
+ end
448
+ html += "<th>Actions</th></tr></thead><tbody>"
449
+
450
+ # Table body
451
+ if records.empty?
452
+ html += "<tr><td colspan=\"#{columns.length + 1}\" style=\"text-align:center;padding:2rem;\">No records found.</td></tr>"
453
+ else
454
+ records.each do |row|
455
+ pk_value = row[pk.to_sym] || row[pk.to_s] || row[pk]
456
+ html += "<tr>"
457
+ columns.each do |col|
458
+ value = row[col.to_sym] || row[col.to_s] || row[col]
459
+ html += "<td>#{h(value)}</td>"
460
+ end
461
+ html += "<td>"
462
+ html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudShowEdit(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Edit</button>"
463
+ html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudShowDelete(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Delete</button>"
464
+ html += "</td></tr>"
465
+ end
466
+ end
467
+ html += "</tbody></table></div>"
468
+
469
+ # Pagination
470
+ if total_pages > 1
471
+ html += "<div class=\"crud-pagination\">"
472
+ if page > 1
473
+ html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page - 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Prev</a>"
474
+ end
475
+ # Show page numbers (max 7)
476
+ start_page = [page - 3, 1].max
477
+ end_page = [start_page + 6, total_pages].min
478
+ start_page = [end_page - 6, 1].max
479
+ (start_page..end_page).each do |p|
480
+ active = p == page ? " btn-primary" : " btn-secondary"
481
+ html += "<a class=\"btn btn-sm#{active}\" href=\"#{h(request_path)}?page=#{p}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">#{p}</a>"
482
+ end
483
+ if page < total_pages
484
+ html += "<a class=\"btn btn-sm btn-secondary\" href=\"#{h(request_path)}?page=#{page + 1}&search=#{URI.encode_www_form_component(search)}&sort=#{h(sort_col)}&sort_dir=#{h(sort_dir)}&limit=#{limit}\">Next</a>"
485
+ end
486
+ html += "</div>"
487
+ end
488
+
489
+ # Create modal
490
+ html += build_modal("create", "Create New Record", editable_columns, pk, api_path, request_path)
491
+
492
+ # Edit modal
493
+ html += build_modal("edit", "Edit Record", editable_columns, pk, api_path, request_path, edit: true)
494
+
495
+ # Delete confirmation modal
496
+ html += <<~HTML
497
+ <div class="modal-overlay" id="modal-delete">
498
+ <div class="modal-box">
499
+ <h3>Confirm Delete</h3>
500
+ <p>Are you sure you want to delete this record? This action cannot be undone.</p>
501
+ <input type="hidden" id="delete-pk-value">
502
+ <div class="modal-footer">
503
+ <button class="btn btn-secondary" onclick="crudCloseModal('delete')">Cancel</button>
504
+ <button class="btn btn-danger" onclick="crudConfirmDelete()">Delete</button>
505
+ </div>
506
+ </div>
507
+ </div>
508
+ HTML
509
+
510
+ # JavaScript
511
+ html += build_crud_javascript(api_path, pk, editable_columns, request_path)
512
+
513
+ html += "</div></body></html>"
514
+ html
515
+ end
516
+
517
+ # Build a create or edit modal
518
+ def build_modal(id, title, columns, pk, api_path, request_path, edit: false)
519
+ html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
520
+ html += "<div class=\"modal-box\">"
521
+ html += "<h3>#{h(title)}</h3>"
522
+ html += "<form id=\"form-#{id}\" onsubmit=\"return false;\">"
523
+ html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
524
+
525
+ columns.each do |col|
526
+ label = pretty_label(col)
527
+ field_id = "#{id}-#{col}"
528
+ html += "<div class=\"mb-3\">"
529
+ html += "<label for=\"#{field_id}\" class=\"form-label\">#{label}</label>"
530
+ html += "<input type=\"text\" class=\"form-control\" id=\"#{field_id}\" name=\"#{col}\" placeholder=\"Enter #{label.downcase}\">"
531
+ html += "</div>"
532
+ end
533
+
534
+ html += "<div class=\"modal-footer\">"
535
+ html += "<button type=\"button\" class=\"btn btn-secondary\" onclick=\"crudCloseModal('#{id}')\">Cancel</button>"
536
+ html += "<button type=\"button\" class=\"btn btn-primary\" onclick=\"crudSave#{edit ? 'Edit' : 'Create'}()\">Save</button>"
537
+ html += "</div></form></div></div>"
538
+ html
539
+ end
540
+
541
+ # Build the JavaScript for the CRUD interface
542
+ def build_crud_javascript(api_path, pk, columns, request_path)
543
+ columns_json = JSON.generate(columns.map(&:to_s))
544
+ <<~HTML
545
+ <script>
546
+ var CRUD_API = '#{api_path}';
547
+ var CRUD_PK = '#{pk}';
548
+ var CRUD_COLUMNS = #{columns_json};
549
+
550
+ function crudShowAlert(message, type) {
551
+ var el = document.getElementById('crud-alert');
552
+ el.className = 'alert alert-' + type;
553
+ el.textContent = message;
554
+ el.style.display = 'block';
555
+ setTimeout(function() { el.style.display = 'none'; }, 3000);
556
+ }
557
+
558
+ function crudShowCreate() {
559
+ var form = document.getElementById('form-create');
560
+ form.reset();
561
+ document.getElementById('modal-create').classList.add('active');
562
+ }
563
+
564
+ function crudShowEdit(id) {
565
+ fetch(CRUD_API + '/' + id)
566
+ .then(function(r) { return r.json(); })
567
+ .then(function(result) {
568
+ var data = result.data || result;
569
+ document.getElementById('edit-pk-value').value = id;
570
+ CRUD_COLUMNS.forEach(function(col) {
571
+ var input = document.getElementById('edit-' + col);
572
+ if (input) input.value = data[col] != null ? data[col] : '';
573
+ });
574
+ document.getElementById('modal-edit').classList.add('active');
575
+ })
576
+ .catch(function(e) { crudShowAlert('Failed to load record: ' + e, 'danger'); });
577
+ }
578
+
579
+ function crudShowDelete(id) {
580
+ document.getElementById('delete-pk-value').value = id;
581
+ document.getElementById('modal-delete').classList.add('active');
582
+ }
583
+
584
+ function crudCloseModal(name) {
585
+ document.getElementById('modal-' + name).classList.remove('active');
586
+ }
587
+
588
+ function crudSaveCreate() {
589
+ var data = {};
590
+ CRUD_COLUMNS.forEach(function(col) {
591
+ var input = document.getElementById('create-' + col);
592
+ if (input) data[col] = input.value;
593
+ });
594
+ fetch(CRUD_API, {
595
+ method: 'POST',
596
+ headers: { 'Content-Type': 'application/json' },
597
+ body: JSON.stringify(data)
598
+ })
599
+ .then(function(r) { return r.json(); })
600
+ .then(function(result) {
601
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
602
+ crudCloseModal('create');
603
+ crudShowAlert('Record created successfully', 'success');
604
+ setTimeout(function() { window.location.reload(); }, 500);
605
+ })
606
+ .catch(function(e) { crudShowAlert('Failed to create: ' + e, 'danger'); });
607
+ }
608
+
609
+ function crudSaveEdit() {
610
+ var id = document.getElementById('edit-pk-value').value;
611
+ var data = {};
612
+ CRUD_COLUMNS.forEach(function(col) {
613
+ var input = document.getElementById('edit-' + col);
614
+ if (input) data[col] = input.value;
615
+ });
616
+ fetch(CRUD_API + '/' + id, {
617
+ method: 'PUT',
618
+ headers: { 'Content-Type': 'application/json' },
619
+ body: JSON.stringify(data)
620
+ })
621
+ .then(function(r) { return r.json(); })
622
+ .then(function(result) {
623
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
624
+ crudCloseModal('edit');
625
+ crudShowAlert('Record updated successfully', 'success');
626
+ setTimeout(function() { window.location.reload(); }, 500);
627
+ })
628
+ .catch(function(e) { crudShowAlert('Failed to update: ' + e, 'danger'); });
629
+ }
630
+
631
+ function crudConfirmDelete() {
632
+ var id = document.getElementById('delete-pk-value').value;
633
+ fetch(CRUD_API + '/' + id, { method: 'DELETE' })
634
+ .then(function(r) { return r.json(); })
635
+ .then(function(result) {
636
+ if (result.error) { crudShowAlert(result.error, 'danger'); return; }
637
+ crudCloseModal('delete');
638
+ crudShowAlert('Record deleted successfully', 'success');
639
+ setTimeout(function() { window.location.reload(); }, 500);
640
+ })
641
+ .catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
642
+ }
643
+
644
+ // Close modal on overlay click
645
+ document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
646
+ overlay.addEventListener('click', function(e) {
647
+ if (e.target === overlay) overlay.classList.remove('active');
648
+ });
649
+ });
650
+
651
+ // Close modal on Escape key
652
+ document.addEventListener('keydown', function(e) {
653
+ if (e.key === 'Escape') {
654
+ document.querySelectorAll('.modal-overlay.active').forEach(function(m) {
655
+ m.classList.remove('active');
656
+ });
657
+ }
658
+ });
659
+ </script>
660
+ HTML
661
+ end
662
+
663
+ def inline_crud_javascript(table_name)
664
+ <<~JS
665
+ <script>
666
+ function crudSave(table, id) {
667
+ const row = document.querySelector(`tr[data-id="${id}"]`);
668
+ const cells = row.querySelectorAll('td[data-field]');
669
+ const data = {};
670
+ cells.forEach(cell => { data[cell.dataset.field] = cell.textContent; });
671
+ fetch(`/api/${table}/${id}`, {
672
+ method: 'PUT',
673
+ headers: { 'Content-Type': 'application/json' },
674
+ body: JSON.stringify(data)
675
+ }).then(r => r.json()).then(d => { alert('Saved!'); }).catch(e => alert('Error: ' + e));
676
+ }
677
+ function crudDelete(table, id) {
678
+ if (!confirm('Delete this record?')) return;
679
+ fetch(`/api/${table}/${id}`, { method: 'DELETE' })
680
+ .then(r => r.json())
681
+ .then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
682
+ .catch(e => alert('Error: ' + e));
683
+ }
684
+ </script>
685
+ JS
686
+ end
687
+ end
688
+ end
689
+
690
+ # Uppercase alias for convenience: Tina4::CRUD.to_crud(...)
691
+ CRUD = Crud
692
+ end