tina4 0.2.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +662 -0
  5. data/exe/tina4 +4 -0
  6. data/lib/tina4/api.rb +152 -0
  7. data/lib/tina4/auth.rb +139 -0
  8. data/lib/tina4/cli.rb +243 -0
  9. data/lib/tina4/crud.rb +124 -0
  10. data/lib/tina4/database.rb +135 -0
  11. data/lib/tina4/database_result.rb +89 -0
  12. data/lib/tina4/debug.rb +83 -0
  13. data/lib/tina4/dev_reload.rb +68 -0
  14. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  15. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  16. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  17. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  18. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  19. data/lib/tina4/env.rb +55 -0
  20. data/lib/tina4/field_types.rb +84 -0
  21. data/lib/tina4/localization.rb +100 -0
  22. data/lib/tina4/middleware.rb +59 -0
  23. data/lib/tina4/migration.rb +124 -0
  24. data/lib/tina4/orm.rb +168 -0
  25. data/lib/tina4/queue.rb +117 -0
  26. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  27. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  28. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  29. data/lib/tina4/rack_app.rb +150 -0
  30. data/lib/tina4/request.rb +158 -0
  31. data/lib/tina4/response.rb +172 -0
  32. data/lib/tina4/router.rb +142 -0
  33. data/lib/tina4/scss_compiler.rb +131 -0
  34. data/lib/tina4/session.rb +145 -0
  35. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  36. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  37. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  38. data/lib/tina4/swagger.rb +123 -0
  39. data/lib/tina4/template.rb +478 -0
  40. data/lib/tina4/templates/base.twig +25 -0
  41. data/lib/tina4/templates/errors/403.twig +22 -0
  42. data/lib/tina4/templates/errors/404.twig +22 -0
  43. data/lib/tina4/templates/errors/500.twig +22 -0
  44. data/lib/tina4/testing.rb +213 -0
  45. data/lib/tina4/version.rb +5 -0
  46. data/lib/tina4/webserver.rb +101 -0
  47. data/lib/tina4/websocket.rb +167 -0
  48. data/lib/tina4/wsdl.rb +164 -0
  49. data/lib/tina4.rb +233 -0
  50. metadata +303 -0
data/lib/tina4/api.rb ADDED
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+ require "net/http"
3
+ require "uri"
4
+ require "json"
5
+
6
+ module Tina4
7
+ class API
8
+ attr_reader :base_url, :headers
9
+
10
+ def initialize(base_url, headers: {}, timeout: 30)
11
+ @base_url = base_url.chomp("/")
12
+ @headers = {
13
+ "Content-Type" => "application/json",
14
+ "Accept" => "application/json"
15
+ }.merge(headers)
16
+ @timeout = timeout
17
+ end
18
+
19
+ def get(path, params: {}, headers: {})
20
+ uri = build_uri(path, params)
21
+ request = Net::HTTP::Get.new(uri)
22
+ apply_headers(request, headers)
23
+ execute(uri, request)
24
+ end
25
+
26
+ def post(path, body: nil, headers: {})
27
+ uri = build_uri(path)
28
+ request = Net::HTTP::Post.new(uri)
29
+ request.body = body.is_a?(String) ? body : JSON.generate(body) if body
30
+ apply_headers(request, headers)
31
+ execute(uri, request)
32
+ end
33
+
34
+ def put(path, body: nil, headers: {})
35
+ uri = build_uri(path)
36
+ request = Net::HTTP::Put.new(uri)
37
+ request.body = body.is_a?(String) ? body : JSON.generate(body) if body
38
+ apply_headers(request, headers)
39
+ execute(uri, request)
40
+ end
41
+
42
+ def patch(path, body: nil, headers: {})
43
+ uri = build_uri(path)
44
+ request = Net::HTTP::Patch.new(uri)
45
+ request.body = body.is_a?(String) ? body : JSON.generate(body) if body
46
+ apply_headers(request, headers)
47
+ execute(uri, request)
48
+ end
49
+
50
+ def delete(path, headers: {})
51
+ uri = build_uri(path)
52
+ request = Net::HTTP::Delete.new(uri)
53
+ apply_headers(request, headers)
54
+ execute(uri, request)
55
+ end
56
+
57
+ def upload(path, file_path, field_name: "file", extra_fields: {}, headers: {})
58
+ uri = build_uri(path)
59
+ boundary = "----Tina4Boundary#{SecureRandom.hex(16)}"
60
+
61
+ body = build_multipart_body(boundary, file_path, field_name, extra_fields)
62
+
63
+ request = Net::HTTP::Post.new(uri)
64
+ request.body = body
65
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
66
+ headers.each { |k, v| request[k] = v }
67
+ execute(uri, request)
68
+ end
69
+
70
+ private
71
+
72
+ def build_uri(path, params = {})
73
+ url = "#{@base_url}#{path}"
74
+ uri = URI.parse(url)
75
+ unless params.empty?
76
+ query = URI.encode_www_form(params)
77
+ uri.query = uri.query ? "#{uri.query}&#{query}" : query
78
+ end
79
+ uri
80
+ end
81
+
82
+ def apply_headers(request, extra_headers)
83
+ @headers.merge(extra_headers).each do |key, value|
84
+ request[key] = value
85
+ end
86
+ end
87
+
88
+ def execute(uri, request)
89
+ http = Net::HTTP.new(uri.host, uri.port)
90
+ http.use_ssl = uri.scheme == "https"
91
+ http.open_timeout = @timeout
92
+ http.read_timeout = @timeout
93
+
94
+ response = http.request(request)
95
+
96
+ APIResponse.new(
97
+ status: response.code.to_i,
98
+ body: response.body,
99
+ headers: response.to_hash
100
+ )
101
+ rescue StandardError => e
102
+ APIResponse.new(
103
+ status: 0,
104
+ body: "",
105
+ headers: {},
106
+ error: e.message
107
+ )
108
+ end
109
+
110
+ def build_multipart_body(boundary, file_path, field_name, extra_fields)
111
+ body = ""
112
+ extra_fields.each do |key, value|
113
+ body += "--#{boundary}\r\n"
114
+ body += "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
115
+ body += "#{value}\r\n"
116
+ end
117
+
118
+ filename = File.basename(file_path)
119
+ body += "--#{boundary}\r\n"
120
+ body += "Content-Disposition: form-data; name=\"#{field_name}\"; filename=\"#{filename}\"\r\n"
121
+ body += "Content-Type: application/octet-stream\r\n\r\n"
122
+ body += File.binread(file_path)
123
+ body += "\r\n--#{boundary}--\r\n"
124
+ body
125
+ end
126
+ end
127
+
128
+ class APIResponse
129
+ attr_reader :status, :body, :headers, :error
130
+
131
+ def initialize(status:, body:, headers:, error: nil)
132
+ @status = status
133
+ @body = body
134
+ @headers = headers
135
+ @error = error
136
+ end
137
+
138
+ def success?
139
+ @status >= 200 && @status < 300
140
+ end
141
+
142
+ def json
143
+ @json ||= JSON.parse(@body)
144
+ rescue JSON::ParserError
145
+ {}
146
+ end
147
+
148
+ def to_s
149
+ "APIResponse(status=#{@status})"
150
+ end
151
+ end
152
+ end
data/lib/tina4/auth.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+ require "openssl"
3
+ require "base64"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Tina4
8
+ module Auth
9
+ KEYS_DIR = ".keys"
10
+
11
+ class << self
12
+ def setup(root_dir = Dir.pwd)
13
+ @keys_dir = File.join(root_dir, KEYS_DIR)
14
+ FileUtils.mkdir_p(@keys_dir)
15
+ ensure_keys
16
+ end
17
+
18
+ def generate_token(payload, expires_in: 3600)
19
+ ensure_keys
20
+ now = Time.now.to_i
21
+ claims = payload.merge(
22
+ "iat" => now,
23
+ "exp" => now + expires_in,
24
+ "nbf" => now
25
+ )
26
+ require "jwt"
27
+ JWT.encode(claims, private_key, "RS256")
28
+ end
29
+
30
+ def validate_token(token)
31
+ ensure_keys
32
+ require "jwt"
33
+ decoded = JWT.decode(token, public_key, true, algorithm: "RS256")
34
+ { valid: true, payload: decoded[0] }
35
+ rescue JWT::ExpiredSignature
36
+ { valid: false, error: "Token expired" }
37
+ rescue JWT::DecodeError => e
38
+ { valid: false, error: e.message }
39
+ end
40
+
41
+ def hash_password(password)
42
+ require "bcrypt"
43
+ BCrypt::Password.create(password)
44
+ end
45
+
46
+ def verify_password(password, hash)
47
+ require "bcrypt"
48
+ BCrypt::Password.new(hash) == password
49
+ rescue BCrypt::Errors::InvalidHash
50
+ false
51
+ end
52
+
53
+ def auth_handler(&block)
54
+ if block_given?
55
+ @custom_handler = block
56
+ else
57
+ @custom_handler || method(:default_auth_handler)
58
+ end
59
+ end
60
+
61
+ def bearer_auth
62
+ lambda do |env|
63
+ auth_header = env["HTTP_AUTHORIZATION"] || ""
64
+ return false unless auth_header =~ /\ABearer\s+(.+)\z/i
65
+
66
+ token = Regexp.last_match(1)
67
+
68
+ # API_KEY bypass — matches tina4_python behavior
69
+ api_key = ENV["API_KEY"]
70
+ if api_key && !api_key.empty? && token == api_key
71
+ env["tina4.auth"] = { "api_key" => true }
72
+ return true
73
+ end
74
+
75
+ result = validate_token(token)
76
+ if result[:valid]
77
+ env["tina4.auth"] = result[:payload]
78
+ true
79
+ else
80
+ false
81
+ end
82
+ end
83
+ end
84
+
85
+ # Default auth handler for secured routes (POST/PUT/PATCH/DELETE)
86
+ # Used automatically unless auth: false is passed
87
+ def default_secure_auth
88
+ @default_secure_auth ||= bearer_auth
89
+ end
90
+
91
+ def private_key
92
+ @private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path))
93
+ end
94
+
95
+ def public_key
96
+ @public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path))
97
+ end
98
+
99
+ private
100
+
101
+ def ensure_keys
102
+ @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
103
+ FileUtils.mkdir_p(@keys_dir)
104
+ unless File.exist?(private_key_path) && File.exist?(public_key_path)
105
+ generate_keys
106
+ end
107
+ end
108
+
109
+ def generate_keys
110
+ Tina4::Debug.info("Generating RSA key pair for JWT authentication")
111
+ key = OpenSSL::PKey::RSA.generate(2048)
112
+ File.write(private_key_path, key.to_pem)
113
+ File.write(public_key_path, key.public_key.to_pem)
114
+ @private_key = nil
115
+ @public_key = nil
116
+ end
117
+
118
+ def private_key_path
119
+ File.join(@keys_dir, "private.pem")
120
+ end
121
+
122
+ def public_key_path
123
+ File.join(@keys_dir, "public.pem")
124
+ end
125
+
126
+ def default_auth_handler(env)
127
+ auth_header = env["HTTP_AUTHORIZATION"] || ""
128
+ return true if auth_header.empty?
129
+
130
+ if auth_header =~ /\ABearer\s+(.+)\z/i
131
+ result = validate_token(Regexp.last_match(1))
132
+ result[:valid]
133
+ else
134
+ false
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
data/lib/tina4/cli.rb ADDED
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+ require "thor"
3
+ require "fileutils"
4
+
5
+ module Tina4
6
+ class CLI < Thor
7
+ desc "init [NAME]", "Initialize a new Tina4 project"
8
+ option :template, type: :string, default: "default", desc: "Project template"
9
+ def init(name = ".")
10
+ dir = name == "." ? Dir.pwd : File.join(Dir.pwd, name)
11
+ FileUtils.mkdir_p(dir)
12
+
13
+ create_project_structure(dir)
14
+ create_sample_files(dir, name == "." ? File.basename(Dir.pwd) : name)
15
+
16
+ puts "Tina4 project initialized in #{dir}"
17
+ puts "Run 'cd #{name} && bundle install && tina4 start' to get started" unless name == "."
18
+ end
19
+
20
+ desc "start", "Start the Tina4 web server"
21
+ option :port, type: :numeric, default: 7145, aliases: "-p"
22
+ option :host, type: :string, default: "0.0.0.0", aliases: "-h"
23
+ option :dev, type: :boolean, default: false, aliases: "-d", desc: "Enable dev mode with auto-reload"
24
+ def start
25
+ require_relative "../tina4"
26
+
27
+ root_dir = Dir.pwd
28
+ Tina4.initialize!(root_dir)
29
+
30
+ # Load route files
31
+ load_routes(root_dir)
32
+
33
+ if options[:dev]
34
+ Tina4::DevReload.start(root_dir: root_dir)
35
+ Tina4::ScssCompiler.compile_all(root_dir)
36
+ end
37
+
38
+ app = Tina4::RackApp.new(root_dir: root_dir)
39
+
40
+ # Try Puma first (production-grade), fall back to WEBrick
41
+ begin
42
+ require "puma"
43
+ require "puma/configuration"
44
+ require "puma/launcher"
45
+
46
+ puma_host = options[:host]
47
+ puma_port = options[:port]
48
+
49
+ config = Puma::Configuration.new do |user_config|
50
+ user_config.bind "tcp://#{puma_host}:#{puma_port}"
51
+ user_config.app app
52
+ user_config.threads 0, 16
53
+ user_config.workers 0
54
+ user_config.environment "development"
55
+ user_config.log_requests false
56
+ user_config.quiet
57
+ end
58
+
59
+ Tina4::Debug.info("Starting Puma server on http://#{puma_host}:#{puma_port}")
60
+ launcher = Puma::Launcher.new(config)
61
+ launcher.run
62
+ rescue LoadError
63
+ Tina4::Debug.info("Puma not found, falling back to WEBrick")
64
+ server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
65
+ server.start
66
+ end
67
+ end
68
+
69
+ desc "migrate", "Run database migrations"
70
+ option :create, type: :string, desc: "Create a new migration"
71
+ option :rollback, type: :numeric, desc: "Rollback N migrations"
72
+ def migrate
73
+ require_relative "../tina4"
74
+ Tina4.initialize!(Dir.pwd)
75
+
76
+ db = Tina4.database
77
+ unless db
78
+ puts "No database configured. Set DATABASE_URL in your .env file."
79
+ return
80
+ end
81
+
82
+ migration = Tina4::Migration.new(db)
83
+
84
+ if options[:create]
85
+ path = migration.create(options[:create])
86
+ puts "Created migration: #{path}"
87
+ elsif options[:rollback]
88
+ migration.rollback(options[:rollback])
89
+ puts "Rolled back #{options[:rollback]} migration(s)"
90
+ else
91
+ results = migration.run
92
+ if results.empty?
93
+ puts "No pending migrations"
94
+ else
95
+ results.each do |r|
96
+ status_icon = r[:status] == "success" ? "OK" : "FAIL"
97
+ puts " [#{status_icon}] #{r[:name]}"
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ desc "test", "Run inline tests"
104
+ def test
105
+ require_relative "../tina4"
106
+ Tina4.initialize!(Dir.pwd)
107
+
108
+ # Load test files
109
+ test_dirs = %w[tests test spec src/tests]
110
+ test_dirs.each do |dir|
111
+ test_dir = File.join(Dir.pwd, dir)
112
+ next unless Dir.exist?(test_dir)
113
+ Dir.glob(File.join(test_dir, "**/*_test.rb")).sort.each { |f| load f }
114
+ Dir.glob(File.join(test_dir, "**/test_*.rb")).sort.each { |f| load f }
115
+ end
116
+
117
+ # Also load inline tests from routes
118
+ load_routes(Dir.pwd)
119
+
120
+ results = Tina4::Testing.run_all
121
+ exit(1) if results[:failed] > 0 || results[:errors] > 0
122
+ end
123
+
124
+ desc "version", "Show Tina4 version"
125
+ def version
126
+ require_relative "version"
127
+ puts "Tina4 Ruby v#{Tina4::VERSION}"
128
+ end
129
+
130
+ desc "routes", "List all registered routes"
131
+ def routes
132
+ require_relative "../tina4"
133
+ Tina4.initialize!(Dir.pwd)
134
+ load_routes(Dir.pwd)
135
+
136
+ puts "\nRegistered Routes:"
137
+ puts "-" * 60
138
+ Tina4::Router.routes.each do |route|
139
+ auth = route.auth_handler ? " [AUTH]" : ""
140
+ puts " #{route.method.ljust(8)} #{route.path}#{auth}"
141
+ end
142
+ puts "-" * 60
143
+ puts "Total: #{Tina4::Router.routes.length} routes\n"
144
+ end
145
+
146
+ desc "console", "Start an interactive console"
147
+ def console
148
+ require_relative "../tina4"
149
+ Tina4.initialize!(Dir.pwd)
150
+ load_routes(Dir.pwd)
151
+
152
+ require "irb"
153
+ IRB.start
154
+ end
155
+
156
+ private
157
+
158
+ def load_routes(root_dir)
159
+ route_dirs = %w[routes src/routes src/api api]
160
+ route_dirs.each do |dir|
161
+ route_dir = File.join(root_dir, dir)
162
+ next unless Dir.exist?(route_dir)
163
+ Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
164
+ end
165
+
166
+ # Also load app.rb if it exists
167
+ app_file = File.join(root_dir, "app.rb")
168
+ load app_file if File.exist?(app_file)
169
+
170
+ index_file = File.join(root_dir, "index.rb")
171
+ load index_file if File.exist?(index_file)
172
+ end
173
+
174
+ def create_project_structure(dir)
175
+ %w[
176
+ routes templates public public/css public/js public/images
177
+ migrations src logs
178
+ ].each do |subdir|
179
+ FileUtils.mkdir_p(File.join(dir, subdir))
180
+ end
181
+ end
182
+
183
+ def create_sample_files(dir, project_name)
184
+ # app.rb
185
+ unless File.exist?(File.join(dir, "app.rb"))
186
+ File.write(File.join(dir, "app.rb"), <<~RUBY)
187
+ require "tina4"
188
+
189
+ Tina4.get "/" do |request, response|
190
+ response.html "<h1>Welcome to #{project_name}!</h1><p>Powered by Tina4 Ruby</p>"
191
+ end
192
+
193
+ Tina4.get "/api/hello" do |request, response|
194
+ response.json({ message: "Hello from Tina4!", timestamp: Time.now.iso8601 })
195
+ end
196
+ RUBY
197
+ end
198
+
199
+ # Gemfile
200
+ unless File.exist?(File.join(dir, "Gemfile"))
201
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
202
+ source "https://rubygems.org"
203
+ gem "tina4"
204
+ RUBY
205
+ end
206
+
207
+ # .gitignore
208
+ unless File.exist?(File.join(dir, ".gitignore"))
209
+ File.write(File.join(dir, ".gitignore"), <<~TEXT)
210
+ .env
211
+ .keys/
212
+ logs/
213
+ sessions/
214
+ .queue/
215
+ *.db
216
+ vendor/
217
+ TEXT
218
+ end
219
+
220
+ # Base template
221
+ templates_dir = File.join(dir, "templates")
222
+ unless File.exist?(File.join(templates_dir, "base.twig"))
223
+ File.write(File.join(templates_dir, "base.twig"), <<~HTML)
224
+ <!DOCTYPE html>
225
+ <html lang="en">
226
+ <head>
227
+ <meta charset="UTF-8">
228
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
229
+ <title>{% block title %}#{project_name}{% endblock %}</title>
230
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
231
+ {% block head %}{% endblock %}
232
+ </head>
233
+ <body>
234
+ {% block content %}{% endblock %}
235
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
236
+ {% block scripts %}{% endblock %}
237
+ </body>
238
+ </html>
239
+ HTML
240
+ end
241
+ end
242
+ end
243
+ end
data/lib/tina4/crud.rb ADDED
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Crud
5
+ class << self
6
+ def generate_table(records, table_name: "data", primary_key: "id", editable: true)
7
+ return "<p>No records found.</p>" if records.nil? || records.empty?
8
+
9
+ columns = records.first.keys
10
+
11
+ html = <<~HTML
12
+ <div class="table-responsive">
13
+ <table class="table table-striped table-hover" id="crud-#{table_name}">
14
+ <thead class="table-dark"><tr>
15
+ HTML
16
+
17
+ columns.each do |col|
18
+ html += "<th>#{col}</th>"
19
+ end
20
+ html += "<th>Actions</th>" if editable
21
+ html += "</tr></thead><tbody>"
22
+
23
+ records.each do |row|
24
+ pk_value = row[primary_key.to_sym] || row[primary_key.to_s]
25
+ html += "<tr data-id=\"#{pk_value}\">"
26
+ columns.each do |col|
27
+ value = row[col]
28
+ if editable
29
+ html += "<td contenteditable=\"true\" data-field=\"#{col}\">#{value}</td>"
30
+ else
31
+ html += "<td>#{value}</td>"
32
+ end
33
+ end
34
+ if editable
35
+ html += "<td>"
36
+ html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudSave('#{table_name}', '#{pk_value}')\">Save</button>"
37
+ html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudDelete('#{table_name}', '#{pk_value}')\">Delete</button>"
38
+ html += "</td>"
39
+ end
40
+ html += "</tr>"
41
+ end
42
+
43
+ html += "</tbody></table></div>"
44
+
45
+ if editable
46
+ html += crud_javascript(table_name)
47
+ end
48
+
49
+ html
50
+ end
51
+
52
+ def generate_form(fields, action: "/", method: "POST", table_name: "data")
53
+ html = "<form action=\"#{action}\" method=\"#{method}\" class=\"needs-validation\" novalidate>"
54
+ html += "<input type=\"hidden\" name=\"_method\" value=\"#{method}\">" if %w[PUT PATCH DELETE].include?(method.upcase)
55
+
56
+ fields.each do |field|
57
+ name = field[:name]
58
+ type = field[:type] || :string
59
+ label = field[:label] || name.to_s.capitalize
60
+ value = field[:value] || ""
61
+ required = field[:required] || false
62
+
63
+ html += "<div class=\"mb-3\">"
64
+ html += "<label for=\"#{name}\" class=\"form-label\">#{label}</label>"
65
+
66
+ case type.to_sym
67
+ when :text
68
+ html += "<textarea class=\"form-control\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>#{value}</textarea>"
69
+ when :boolean
70
+ checked = value ? "checked" : ""
71
+ html += "<div class=\"form-check\">"
72
+ html += "<input class=\"form-check-input\" type=\"checkbox\" id=\"#{name}\" name=\"#{name}\" #{checked}>"
73
+ html += "</div>"
74
+ when :select
75
+ html += "<select class=\"form-select\" id=\"#{name}\" name=\"#{name}\" #{'required' if required}>"
76
+ (field[:options] || []).each do |opt|
77
+ selected = opt[:value].to_s == value.to_s ? "selected" : ""
78
+ html += "<option value=\"#{opt[:value]}\" #{selected}>#{opt[:label]}</option>"
79
+ end
80
+ html += "</select>"
81
+ when :date
82
+ html += "<input type=\"date\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
83
+ when :integer, :number
84
+ html += "<input type=\"number\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
85
+ else
86
+ html += "<input type=\"text\" class=\"form-control\" id=\"#{name}\" name=\"#{name}\" value=\"#{value}\" #{'required' if required}>"
87
+ end
88
+ html += "</div>"
89
+ end
90
+
91
+ html += "<button type=\"submit\" class=\"btn btn-primary\">Submit</button>"
92
+ html += "</form>"
93
+ html
94
+ end
95
+
96
+ private
97
+
98
+ def crud_javascript(table_name)
99
+ <<~JS
100
+ <script>
101
+ function crudSave(table, id) {
102
+ const row = document.querySelector(`tr[data-id="${id}"]`);
103
+ const cells = row.querySelectorAll('td[data-field]');
104
+ const data = {};
105
+ cells.forEach(cell => { data[cell.dataset.field] = cell.textContent; });
106
+ fetch(`/api/${table}/${id}`, {
107
+ method: 'PUT',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify(data)
110
+ }).then(r => r.json()).then(d => { alert('Saved!'); }).catch(e => alert('Error: ' + e));
111
+ }
112
+ function crudDelete(table, id) {
113
+ if (!confirm('Delete this record?')) return;
114
+ fetch(`/api/${table}/${id}`, { method: 'DELETE' })
115
+ .then(r => r.json())
116
+ .then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
117
+ .catch(e => alert('Error: ' + e));
118
+ }
119
+ </script>
120
+ JS
121
+ end
122
+ end
123
+ end
124
+ end