tina4ruby 0.5.2 → 3.0.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +360 -559
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +242 -77
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +43 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1336 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +484 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +337 -31
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +40 -4
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +314 -23
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +134 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +57 -21
  88. metadata +51 -19
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
data/lib/tina4/queue.rb CHANGED
@@ -44,7 +44,7 @@ module Tina4
44
44
  def publish(topic, payload)
45
45
  message = QueueMessage.new(topic: topic, payload: payload)
46
46
  @backend.enqueue(message)
47
- Tina4::Debug.debug("Message published to #{topic}: #{message.id}")
47
+ Tina4::Log.debug("Message published to #{topic}: #{message.id}")
48
48
  message
49
49
  end
50
50
 
@@ -53,6 +53,42 @@ module Tina4
53
53
  end
54
54
  end
55
55
 
56
+ # Queue — convenience wrapper for queue management operations.
57
+ # Provides dead letter inspection, purging, and retry capabilities.
58
+ class Queue
59
+ attr_reader :topic, :max_retries
60
+
61
+ def initialize(topic:, backend: nil, max_retries: 3)
62
+ @topic = topic
63
+ @backend = backend || Tina4::QueueBackends::LiteBackend.new
64
+ @max_retries = max_retries
65
+ end
66
+
67
+ # Get dead letter jobs — messages that exceeded max retries.
68
+ def dead_letters
69
+ return [] unless @backend.respond_to?(:dead_letters)
70
+ @backend.dead_letters(@topic, max_retries: @max_retries)
71
+ end
72
+
73
+ # Delete messages by status (completed, failed, dead).
74
+ def purge(status)
75
+ return 0 unless @backend.respond_to?(:purge)
76
+ @backend.purge(@topic, status)
77
+ end
78
+
79
+ # Re-queue failed messages (under max_retries) back to pending.
80
+ # Returns the number of jobs re-queued.
81
+ def retry_failed
82
+ return 0 unless @backend.respond_to?(:retry_failed)
83
+ @backend.retry_failed(@topic, max_retries: @max_retries)
84
+ end
85
+
86
+ # Get the number of pending messages.
87
+ def size
88
+ @backend.size(@topic)
89
+ end
90
+ end
91
+
56
92
  class Consumer
57
93
  def initialize(topic:, backend: nil, max_retries: 3)
58
94
  @topic = topic
@@ -68,7 +104,7 @@ module Tina4
68
104
 
69
105
  def start(poll_interval: 1)
70
106
  @running = true
71
- Tina4::Debug.info("Consumer started for topic: #{@topic}")
107
+ Tina4::Log.info("Consumer started for topic: #{@topic}")
72
108
 
73
109
  while @running
74
110
  message = @backend.dequeue(@topic)
@@ -82,7 +118,7 @@ module Tina4
82
118
 
83
119
  def stop
84
120
  @running = false
85
- Tina4::Debug.info("Consumer stopped for topic: #{@topic}")
121
+ Tina4::Log.info("Consumer stopped for topic: #{@topic}")
86
122
  end
87
123
 
88
124
  def process_one
@@ -103,7 +139,7 @@ module Tina4
103
139
  message.status = :completed
104
140
  @backend.acknowledge(message)
105
141
  rescue => e
106
- Tina4::Debug.error("Queue message failed: #{message.id} - #{e.message}")
142
+ Tina4::Log.error("Queue message failed: #{message.id} - #{e.message}")
107
143
  message.status = :failed
108
144
 
109
145
  if message.attempts < @max_retries
@@ -68,6 +68,94 @@ module Tina4
68
68
  .select { |d| File.directory?(File.join(@dir, d)) }
69
69
  end
70
70
 
71
+ # Get dead letter jobs for a topic — messages that exceeded max retries.
72
+ def dead_letters(topic, max_retries: 3)
73
+ return [] unless Dir.exist?(@dead_letter_dir)
74
+
75
+ files = Dir.glob(File.join(@dead_letter_dir, "*.json")).sort_by { |f| File.mtime(f) }
76
+ jobs = []
77
+
78
+ files.each do |file|
79
+ data = JSON.parse(File.read(file))
80
+ next unless data["topic"] == topic.to_s
81
+ data["status"] = "dead"
82
+ jobs << data
83
+ rescue JSON::ParserError
84
+ next
85
+ end
86
+
87
+ jobs
88
+ end
89
+
90
+ # Delete messages by status (completed, failed, dead).
91
+ # For 'dead', removes from the dead_letter directory.
92
+ # For 'failed', removes from the topic directory (re-queued failed messages).
93
+ # Returns the number of jobs purged.
94
+ def purge(topic, status)
95
+ count = 0
96
+
97
+ if status.to_s == "dead"
98
+ return 0 unless Dir.exist?(@dead_letter_dir)
99
+
100
+ Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
101
+ data = JSON.parse(File.read(file))
102
+ if data["topic"] == topic.to_s
103
+ File.delete(file)
104
+ count += 1
105
+ end
106
+ rescue JSON::ParserError
107
+ next
108
+ end
109
+ elsif status.to_s == "failed" || status.to_s == "completed" || status.to_s == "pending"
110
+ dir = topic_path(topic)
111
+ return 0 unless Dir.exist?(dir)
112
+
113
+ Dir.glob(File.join(dir, "*.json")).each do |file|
114
+ data = JSON.parse(File.read(file))
115
+ if data["status"] == status.to_s
116
+ File.delete(file)
117
+ count += 1
118
+ end
119
+ rescue JSON::ParserError
120
+ next
121
+ end
122
+ end
123
+
124
+ count
125
+ end
126
+
127
+ # Re-queue failed messages (under max_retries) back to pending.
128
+ # Returns the number of jobs re-queued.
129
+ def retry_failed(topic, max_retries: 3)
130
+ return 0 unless Dir.exist?(@dead_letter_dir)
131
+
132
+ dir = topic_path(topic)
133
+ FileUtils.mkdir_p(dir)
134
+ count = 0
135
+
136
+ # Dead letter directory contains messages that the Consumer moved there.
137
+ # Only retry those whose attempts are under max_retries.
138
+ Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
139
+ data = JSON.parse(File.read(file))
140
+ next unless data["topic"] == topic.to_s
141
+ next if (data["attempts"] || 0) >= max_retries
142
+
143
+ data["status"] = "pending"
144
+ msg = Tina4::QueueMessage.new(
145
+ topic: data["topic"],
146
+ payload: data["payload"],
147
+ id: data["id"]
148
+ )
149
+ enqueue(msg)
150
+ File.delete(file)
151
+ count += 1
152
+ rescue JSON::ParserError
153
+ next
154
+ end
155
+
156
+ count
157
+ end
158
+
71
159
  private
72
160
 
73
161
  def topic_path(topic)
@@ -1,34 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
  require "json"
3
+ require "securerandom"
3
4
 
4
5
  module Tina4
5
6
  class RackApp
6
7
  STATIC_DIRS = %w[public src/public src/assets assets].freeze
7
8
 
8
- # Pre-built frozen responses for zero-allocation fast paths
9
- CORS_HEADERS = {
10
- "access-control-allow-origin" => "*",
11
- "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
12
- "access-control-allow-headers" => "Content-Type, Authorization, Accept",
13
- "access-control-max-age" => "86400"
14
- }.freeze
9
+ # CORS is now handled by Tina4::CorsMiddleware
15
10
 
16
- OPTIONS_RESPONSE = [204, CORS_HEADERS, [""]].freeze
11
+ # Framework's own public directory (bundled static assets like the logo)
12
+ FRAMEWORK_PUBLIC_DIR = File.expand_path("public", __dir__).freeze
17
13
 
18
14
  def initialize(root_dir: Dir.pwd)
19
15
  @root_dir = root_dir
20
16
  # Pre-compute static roots at boot (not per-request)
21
- @static_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
22
- .select { |d| Dir.exist?(d) }
23
- .freeze
17
+ # Project dirs are checked first; framework's bundled public dir is the fallback
18
+ project_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
19
+ .select { |d| Dir.exist?(d) }
20
+ fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
21
+ @static_roots = (project_roots + fallback).freeze
24
22
  end
25
23
 
26
24
  def call(env)
27
25
  method = env["REQUEST_METHOD"]
28
26
  path = env["PATH_INFO"] || "/"
27
+ request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
28
 
30
- # Fast-path: OPTIONS preflight (zero allocation)
31
- return OPTIONS_RESPONSE if method == "OPTIONS"
29
+ # Fast-path: OPTIONS preflight
30
+ return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
31
+
32
+ # Dev dashboard routes (handled before anything else)
33
+ if path.start_with?("/__dev")
34
+ dev_response = Tina4::DevAdmin.handle_request(env)
35
+ return dev_response if dev_response
36
+ end
32
37
 
33
38
  # Fast-path: API routes skip static file + swagger checks entirely
34
39
  unless path.start_with?("/api/")
@@ -49,10 +54,41 @@ module Tina4
49
54
  result = Tina4::Router.find_route(path, method)
50
55
  if result
51
56
  route, path_params = result
52
- handle_route(env, route, path_params)
57
+ rack_response = handle_route(env, route, path_params)
58
+ matched_pattern = route.path
53
59
  else
54
- handle_404(path)
60
+ rack_response = handle_404(path)
61
+ matched_pattern = nil
62
+ end
63
+
64
+ # Capture request for dev inspector
65
+ if dev_mode? && !path.start_with?("/__dev")
66
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
67
+ Tina4::DevAdmin.request_inspector.capture(
68
+ method: method,
69
+ path: path,
70
+ status: rack_response[0],
71
+ duration: duration_ms
72
+ )
73
+ end
74
+
75
+ # Inject dev overlay button for HTML responses in dev mode
76
+ if dev_mode? && !path.start_with?("/__dev")
77
+ status, headers, body_parts = rack_response
78
+ content_type = headers["content-type"] || ""
79
+ if content_type.include?("text/html")
80
+ request_info = {
81
+ method: method,
82
+ path: path,
83
+ matched_pattern: matched_pattern || "(no match)",
84
+ }
85
+ joined = body_parts.join
86
+ overlay = inject_dev_overlay(joined, request_info)
87
+ rack_response = [status, headers, [overlay]]
88
+ end
55
89
  end
90
+
91
+ rack_response
56
92
  rescue => e
57
93
  handle_500(e)
58
94
  end
@@ -63,15 +99,30 @@ module Tina4
63
99
  # Auth check
64
100
  if route.auth_handler
65
101
  auth_result = route.auth_handler.call(env)
66
- return handle_403 unless auth_result
102
+ return handle_403(env["PATH_INFO"] || "/") unless auth_result
67
103
  end
68
104
 
69
105
  request = Tina4::Request.new(env, path_params)
70
106
  response = Tina4::Response.new
71
107
 
108
+ # Run per-route middleware
109
+ if route.respond_to?(:run_middleware)
110
+ unless route.run_middleware(request, response)
111
+ return [403, { "content-type" => "text/html" }, ["403 Forbidden"]]
112
+ end
113
+ end
114
+
72
115
  # Execute handler
73
116
  result = route.handler.call(request, response)
74
117
 
118
+ # Template rendering: when a template is set and the handler returned a Hash,
119
+ # render the template with the hash as data and return the HTML response.
120
+ if route.template && result.is_a?(Hash)
121
+ html = Tina4::Template.render(route.template, result)
122
+ response.html(html)
123
+ return response.to_rack
124
+ end
125
+
75
126
  # Skip auto_detect if handler already returned the response object
76
127
  final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
77
128
  final_response.to_rack
@@ -129,22 +180,262 @@ module Tina4
129
180
  [200, { "content-type" => "application/json; charset=utf-8" }, [@openapi_json]]
130
181
  end
131
182
 
132
- def handle_403
133
- body = Tina4::Template.render_error(403) rescue "403 Forbidden"
183
+ def handle_403(path = "")
184
+ body = Tina4::Template.render_error(403, { "path" => path }) rescue "403 Forbidden"
134
185
  [403, { "content-type" => "text/html" }, [body]]
135
186
  end
136
187
 
137
188
  def handle_404(path)
138
- Tina4::Debug.warning("404 Not Found: #{path}")
139
- body = Tina4::Template.render_error(404) rescue "404 Not Found"
189
+ # Show landing page for GET "/" when no user route or template index exists
190
+ if path == "/" && should_show_landing_page?
191
+ return render_landing_page
192
+ end
193
+
194
+ Tina4::Log.warning("404 Not Found: #{path}")
195
+ body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
140
196
  [404, { "content-type" => "text/html" }, [body]]
141
197
  end
142
198
 
199
+ def should_show_landing_page?
200
+ # Check if any index template exists in src/templates/
201
+ templates_dir = File.join(@root_dir, "src", "templates")
202
+ %w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
203
+ end
204
+
205
+ def render_landing_page
206
+ port = ENV["PORT"] || "7145"
207
+
208
+ # Check deployed state for each gallery item
209
+ project_src = File.join(@root_dir, "src")
210
+ gallery_items = [
211
+ { id: "rest-api", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", icon: "&#128640;", accent: "red", try_url: "/api/gallery/hello", file_check: "routes/api/gallery_hello.rb" },
212
+ { id: "orm", name: "ORM", desc: "Product model with CRUD endpoints", icon: "&#128451;", accent: "green", try_url: "/api/gallery/products", file_check: "routes/api/gallery_products.rb" },
213
+ { id: "auth", name: "Auth", desc: "JWT login form with token display", icon: "&#128274;", accent: "purple", try_url: "/gallery/auth", file_check: "routes/api/gallery_auth.rb" },
214
+ { id: "queue", name: "Queue", desc: "Background job producer and consumer", icon: "&#9889;", accent: "red", try_url: "/api/gallery/queue/produce", file_check: "routes/api/gallery_queue.rb" },
215
+ { id: "templates", name: "Templates", desc: "Twig template with dynamic data", icon: "&#128196;", accent: "green", try_url: "/gallery/page", file_check: "routes/gallery_page.rb" },
216
+ { id: "database", name: "Database", desc: "Raw SQL queries with the Database class", icon: "&#128225;", accent: "purple", try_url: "/api/gallery/db/tables", file_check: "routes/api/gallery_db.rb" },
217
+ { id: "error-overlay", name: "Error Overlay", desc: "See the rich debug error page with stack trace", icon: "&#128165;", accent: "red", try_url: "/api/gallery/crash", file_check: "routes/api/gallery_crash.rb" }
218
+ ]
219
+
220
+ gallery_cards = gallery_items.map do |item|
221
+ deployed = File.file?(File.join(project_src, item[:file_check]))
222
+ deployed_badge = deployed ? '<span style="position:absolute;top:0.75rem;right:0.75rem;background:#22c55e;color:#fff;font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:0.25rem;font-weight:600;">DEPLOYED</span>' : ''
223
+ try_btn = if deployed
224
+ %(<a href="#{item[:try_url]}" class="gbtn gbtn-try" target="_blank">Try It</a>)
225
+ else
226
+ %(<button class="gbtn gbtn-deploy" onclick="deployGallery('#{item[:id]}','#{item[:try_url]}')">Deploy &amp; Try</button>)
227
+ end
228
+ view_btn = %(<button class="gbtn gbtn-view" onclick="viewGallery('#{item[:id]}')">View</button>)
229
+
230
+ <<~CARD
231
+ <div class="gallery-card">
232
+ <div class="accent accent-#{item[:accent]}"></div>
233
+ #{deployed_badge}
234
+ <div class="icon">#{item[:icon]}</div>
235
+ <h3>#{item[:name]}</h3>
236
+ <p>#{item[:desc]}</p>
237
+ <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">#{try_btn}#{view_btn}</div>
238
+ </div>
239
+ CARD
240
+ end.join
241
+
242
+ html = <<~HTML
243
+ <!DOCTYPE html>
244
+ <html lang="en">
245
+ <head>
246
+ <meta charset="utf-8">
247
+ <meta name="viewport" content="width=device-width, initial-scale=1">
248
+ <title>Tina4Ruby</title>
249
+ <style>
250
+ *{margin:0;padding:0;box-sizing:border-box}
251
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
252
+ .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
253
+ .hero{text-align:center;z-index:1;padding:3rem 2rem 2rem}
254
+ .logo{width:120px;height:120px;margin-bottom:1.5rem}
255
+ h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
256
+ .tagline{color:#64748b;font-size:1.1rem;margin-bottom:2rem}
257
+ .actions{display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap;margin-bottom:2.5rem}
258
+ .btn{padding:0.6rem 1.5rem;border-radius:0.5rem;font-size:0.9rem;font-weight:600;cursor:pointer;text-decoration:none;transition:all 0.15s;border:1px solid #334155;color:#94a3b8;background:transparent;min-width:140px;text-align:center;display:inline-block}
259
+ .btn:hover{border-color:#64748b;color:#e2e8f0}
260
+ .status{display:flex;gap:2rem;justify-content:center;align-items:center;color:#64748b;font-size:0.85rem;margin-bottom:1.5rem}
261
+ .status .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;margin-right:0.4rem}
262
+ .footer{color:#334155;font-size:0.8rem;letter-spacing:0.5px}
263
+ .section{z-index:1;width:100%;max-width:800px;padding:0 2rem;margin-bottom:2.5rem}
264
+ .card{background:#1e293b;border-radius:0.75rem;padding:2rem;border:1px solid #334155}
265
+ .card h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0}
266
+ .code-block{background:#0f172a;border-radius:0.5rem;padding:1.25rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:0.85rem;line-height:1.6;color:#4ade80;border:1px solid #1e293b}
267
+ .gallery{z-index:1;width:100%;max-width:900px;padding:0 2rem;margin-bottom:3rem}
268
+ .gallery h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0;text-align:center}
269
+ .gallery-card{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}
270
+ .gallery-card .accent{position:absolute;top:0;left:0;right:0;height:3px}
271
+ .gallery-card .accent-red{background:#CC342D}
272
+ .gallery-card .accent-green{background:#22c55e}
273
+ .gallery-card .accent-purple{background:#a78bfa}
274
+ .gallery-card .icon{font-size:1.5rem;margin-bottom:0.75rem}
275
+ .gallery-card h3{font-size:1rem;font-weight:600;margin-bottom:0.5rem;color:#e2e8f0}
276
+ .gallery-card p{font-size:0.85rem;color:#94a3b8;line-height:1.5}
277
+ .gbtn{padding:0.35rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;font-weight:600;cursor:pointer;text-decoration:none;border:none;transition:all 0.15s}
278
+ .gbtn-try{background:#22c55e;color:#fff}
279
+ .gbtn-try:hover{background:#16a34a}
280
+ .gbtn-deploy{background:#CC342D;color:#fff}
281
+ .gbtn-deploy:hover{background:#a12a24}
282
+ .gbtn-view{background:transparent;color:#94a3b8;border:1px solid #334155}
283
+ .gbtn-view:hover{border-color:#64748b;color:#e2e8f0}
284
+ .view-modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:10000;align-items:center;justify-content:center}
285
+ .view-modal.active{display:flex}
286
+ .view-modal-content{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:2rem;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;position:relative}
287
+ .view-modal-close{position:absolute;top:0.75rem;right:1rem;color:#94a3b8;cursor:pointer;font-size:1.25rem;background:none;border:none}
288
+ .view-modal-close:hover{color:#e2e8f0}
289
+ </style>
290
+ </head>
291
+ <body>
292
+ <img src="/images/tina4-logo-icon.webp" class="bg-watermark" alt="">
293
+ <div class="hero">
294
+ <img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
295
+ <h1>Tina4Ruby</h1>
296
+ <p class="tagline">This is not a framework</p>
297
+ <div class="actions">
298
+ <a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
299
+ <a href="/__dev" class="btn">Dev Admin</a>
300
+ <a href="#gallery" class="btn">Gallery</a>
301
+ <a href="https://github.com/tina4stack/tina4-ruby" class="btn" target="_blank">GitHub</a>
302
+ <a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank">&#11088; Star</a>
303
+ </div>
304
+ <div class="status">
305
+ <span><span class="dot"></span>Server running</span>
306
+ <span>Port #{port}</span>
307
+ <span>v#{Tina4::VERSION}</span>
308
+ </div>
309
+ <p class="footer">Zero dependencies &middot; Convention over configuration</p>
310
+ </div>
311
+ <div class="section">
312
+ <div class="card">
313
+ <h2>Getting Started</h2>
314
+ <pre class="code-block"><code><span style="color:#64748b"># app.rb</span>
315
+ <span style="color:#c084fc">require</span> <span style="color:#4ade80">"tina4"</span>
316
+
317
+ Tina4::Router.<span style="color:#38bdf8">get</span>(<span style="color:#4ade80">"/hello"</span>) <span style="color:#c084fc">do</span> |request, response|
318
+ response.<span style="color:#38bdf8">json</span>({ <span style="color:#fbbf24">message:</span> <span style="color:#4ade80">"Hello World!"</span> })
319
+ <span style="color:#c084fc">end</span>
320
+
321
+ Tina4::WebServer.new(<span style="color:#fbbf24">port:</span> <span style="color:#38bdf8">7145</span>).start <span style="color:#64748b"># starts on port 7145</span></code></pre>
322
+ </div>
323
+ </div>
324
+ <div class="gallery">
325
+ <h2 id="gallery">Gallery</h2>
326
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
327
+ #{gallery_cards}
328
+ </div>
329
+ </div>
330
+ <div class="view-modal" id="viewModal">
331
+ <div class="view-modal-content">
332
+ <button class="view-modal-close" onclick="document.getElementById('viewModal').classList.remove('active')">&times;</button>
333
+ <h3 id="viewModalTitle" style="margin-bottom:1rem;color:#e2e8f0;"></h3>
334
+ <div id="viewModalBody"></div>
335
+ </div>
336
+ </div>
337
+ <script>
338
+ function deployGallery(name, tryUrl) {
339
+ if (!confirm('Deploy the "' + name + '" gallery example into your project?')) return;
340
+ var btn = event.target;
341
+ btn.disabled = true;
342
+ btn.textContent = 'Deploying...';
343
+ fetch('/__dev/api/gallery/deploy', {
344
+ method: 'POST',
345
+ headers: {'Content-Type': 'application/json'},
346
+ body: JSON.stringify({ name: name })
347
+ }).then(function(r) { return r.json(); }).then(function(d) {
348
+ if (d.error) {
349
+ alert('Deploy failed: ' + d.error);
350
+ btn.disabled = false;
351
+ btn.textContent = 'Deploy & Try';
352
+ } else {
353
+ window.location.href = tryUrl;
354
+ }
355
+ }).catch(function(e) {
356
+ alert('Deploy error: ' + e.message);
357
+ btn.disabled = false;
358
+ btn.textContent = 'Deploy & Try';
359
+ });
360
+ }
361
+ function viewGallery(name) {
362
+ fetch('/__dev/api/gallery').then(function(r) { return r.json(); }).then(function(d) {
363
+ var item = (d.gallery || []).find(function(g) { return g.id === name; });
364
+ if (!item) { alert('Gallery item not found'); return; }
365
+ var title = document.getElementById('viewModalTitle');
366
+ var body = document.getElementById('viewModalBody');
367
+ title.textContent = item.name + ' — ' + item.description;
368
+ var html = '<p style="color:#94a3b8;margin-bottom:1rem;">Files that will be deployed:</p><ul style="list-style:none;padding:0;">';
369
+ (item.files || []).forEach(function(f) {
370
+ html += '<li style="padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem;">src/' + f + '</li>';
371
+ });
372
+ html += '</ul>';
373
+ if (item.try_url) {
374
+ html += '<p style="color:#94a3b8;margin-top:1rem;">Try URL: <code style="color:#38bdf8;">' + item.try_url + '</code></p>';
375
+ }
376
+ body.innerHTML = html;
377
+ document.getElementById('viewModal').classList.add('active');
378
+ });
379
+ }
380
+ document.getElementById('viewModal').addEventListener('click', function(e) {
381
+ if (e.target === this) this.classList.remove('active');
382
+ });
383
+ </script>
384
+ </body>
385
+ </html>
386
+ HTML
387
+
388
+ [200, { "content-type" => "text/html; charset=utf-8" }, [html]]
389
+ end
390
+
143
391
  def handle_500(error)
144
- Tina4::Debug.error("500 Internal Server Error: #{error.message}")
145
- Tina4::Debug.error(error.backtrace&.first(10)&.join("\n"))
146
- body = Tina4::Template.render_error(500) rescue "500 Internal Server Error: #{error.message}"
392
+ Tina4::Log.error("500 Internal Server Error: #{error.message}")
393
+ Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
394
+ if dev_mode?
395
+ # Rich error overlay with stack trace, source context, and line numbers
396
+ body = Tina4::ErrorOverlay.render(error)
397
+ else
398
+ body = Tina4::Template.render_error(500, {
399
+ "error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
400
+ "request_id" => SecureRandom.hex(6)
401
+ }) rescue "500 Internal Server Error: #{error.message}"
402
+ end
147
403
  [500, { "content-type" => "text/html" }, [body]]
148
404
  end
405
+
406
+ def dev_mode?
407
+ Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
408
+ end
409
+
410
+ def inject_dev_overlay(body, request_info)
411
+ version = Tina4::VERSION
412
+ method = request_info[:method]
413
+ path = request_info[:path]
414
+ matched_pattern = request_info[:matched_pattern]
415
+ request_id = Tina4::Log.request_id || "-"
416
+ route_count = Tina4::Router.routes.length
417
+
418
+ toolbar = <<~HTML.strip
419
+ <div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
420
+ <span style="color:#d32f2f;font-weight:bold;">Tina4 v#{version}</span>
421
+ <span style="color:#4caf50;">#{method}</span>
422
+ <span>#{path}</span>
423
+ <span style="color:#666;">&rarr; #{matched_pattern}</span>
424
+ <span style="color:#ffeb3b;">req:#{request_id}</span>
425
+ <span style="color:#90caf9;">#{route_count} routes</span>
426
+ <span style="color:#888;">Ruby #{RUBY_VERSION}</span>
427
+ <a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;bottom:2rem;right:1rem;width:min(90vw,1200px);height:min(80vh,700px);z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #CC342D;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
428
+ <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
429
+ </div>
430
+ HTML
431
+
432
+ if body.include?("</body>")
433
+ body.sub("</body>", "#{toolbar}\n</body>")
434
+ else
435
+ body + "\n" + toolbar
436
+ end
437
+ end
438
+
439
+
149
440
  end
150
441
  end