tina4ruby 0.4.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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +768 -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 +349 -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.rb +15 -0
  14. data/lib/tina4/dev_reload.rb +68 -0
  15. data/lib/tina4/drivers/firebird_driver.rb +94 -0
  16. data/lib/tina4/drivers/mssql_driver.rb +112 -0
  17. data/lib/tina4/drivers/mysql_driver.rb +90 -0
  18. data/lib/tina4/drivers/postgres_driver.rb +99 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +85 -0
  20. data/lib/tina4/env.rb +55 -0
  21. data/lib/tina4/field_types.rb +84 -0
  22. data/lib/tina4/graphql.rb +837 -0
  23. data/lib/tina4/localization.rb +100 -0
  24. data/lib/tina4/middleware.rb +59 -0
  25. data/lib/tina4/migration.rb +124 -0
  26. data/lib/tina4/orm.rb +168 -0
  27. data/lib/tina4/public/css/tina4.css +2286 -0
  28. data/lib/tina4/public/css/tina4.min.css +2 -0
  29. data/lib/tina4/public/js/tina4.js +134 -0
  30. data/lib/tina4/public/js/tina4helper.js +387 -0
  31. data/lib/tina4/queue.rb +117 -0
  32. data/lib/tina4/queue_backends/kafka_backend.rb +80 -0
  33. data/lib/tina4/queue_backends/lite_backend.rb +79 -0
  34. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -0
  35. data/lib/tina4/rack_app.rb +150 -0
  36. data/lib/tina4/request.rb +158 -0
  37. data/lib/tina4/response.rb +172 -0
  38. data/lib/tina4/router.rb +148 -0
  39. data/lib/tina4/scss/tina4css/_alerts.scss +34 -0
  40. data/lib/tina4/scss/tina4css/_badges.scss +22 -0
  41. data/lib/tina4/scss/tina4css/_buttons.scss +69 -0
  42. data/lib/tina4/scss/tina4css/_cards.scss +49 -0
  43. data/lib/tina4/scss/tina4css/_forms.scss +156 -0
  44. data/lib/tina4/scss/tina4css/_grid.scss +81 -0
  45. data/lib/tina4/scss/tina4css/_modals.scss +84 -0
  46. data/lib/tina4/scss/tina4css/_nav.scss +149 -0
  47. data/lib/tina4/scss/tina4css/_reset.scss +94 -0
  48. data/lib/tina4/scss/tina4css/_tables.scss +54 -0
  49. data/lib/tina4/scss/tina4css/_typography.scss +55 -0
  50. data/lib/tina4/scss/tina4css/_utilities.scss +197 -0
  51. data/lib/tina4/scss/tina4css/_variables.scss +117 -0
  52. data/lib/tina4/scss/tina4css/base.scss +1 -0
  53. data/lib/tina4/scss/tina4css/colors.scss +48 -0
  54. data/lib/tina4/scss/tina4css/tina4.scss +17 -0
  55. data/lib/tina4/scss_compiler.rb +131 -0
  56. data/lib/tina4/seeder.rb +529 -0
  57. data/lib/tina4/session.rb +145 -0
  58. data/lib/tina4/session_handlers/file_handler.rb +55 -0
  59. data/lib/tina4/session_handlers/mongo_handler.rb +49 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +43 -0
  61. data/lib/tina4/swagger.rb +123 -0
  62. data/lib/tina4/template.rb +478 -0
  63. data/lib/tina4/templates/base.twig +26 -0
  64. data/lib/tina4/templates/errors/403.twig +22 -0
  65. data/lib/tina4/templates/errors/404.twig +22 -0
  66. data/lib/tina4/templates/errors/500.twig +22 -0
  67. data/lib/tina4/testing.rb +213 -0
  68. data/lib/tina4/version.rb +5 -0
  69. data/lib/tina4/webserver.rb +101 -0
  70. data/lib/tina4/websocket.rb +167 -0
  71. data/lib/tina4/wsdl.rb +164 -0
  72. data/lib/tina4.rb +259 -0
  73. data/lib/tina4ruby.rb +4 -0
  74. metadata +324 -0
@@ -0,0 +1,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Template
5
+ TEMPLATE_DIRS = %w[templates src/templates src/views views].freeze
6
+
7
+ class << self
8
+ def globals
9
+ @globals ||= {}
10
+ end
11
+
12
+ def add_global(key, value)
13
+ globals[key.to_s] = value
14
+ end
15
+
16
+ def render(template_path, data = {})
17
+ full_path = resolve_path(template_path)
18
+ unless full_path && File.exist?(full_path)
19
+ raise "Template not found: #{template_path}"
20
+ end
21
+
22
+ content = File.read(full_path)
23
+ ext = File.extname(full_path).downcase
24
+ context = globals.merge(data.transform_keys(&:to_s))
25
+
26
+ case ext
27
+ when ".twig", ".html", ".tina4"
28
+ TwigEngine.new(context, File.dirname(full_path)).render(content)
29
+ when ".erb"
30
+ ErbEngine.render(content, context)
31
+ else
32
+ TwigEngine.new(context, File.dirname(full_path)).render(content)
33
+ end
34
+ end
35
+
36
+ def render_error(code)
37
+ error_dirs = TEMPLATE_DIRS.map { |d| File.join(Dir.pwd, d, "errors") }
38
+ error_dirs << File.join(File.dirname(__FILE__), "templates", "errors")
39
+
40
+ error_dirs.each do |dir|
41
+ %w[.twig .html .erb].each do |ext|
42
+ path = File.join(dir, "#{code}#{ext}")
43
+ if File.exist?(path)
44
+ content = File.read(path)
45
+ return TwigEngine.new({ "code" => code }, dir).render(content)
46
+ end
47
+ end
48
+ end
49
+ default_error_html(code)
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_path(template_path)
55
+ return template_path if File.exist?(template_path)
56
+ TEMPLATE_DIRS.each do |dir|
57
+ full = File.join(Dir.pwd, dir, template_path)
58
+ return full if File.exist?(full)
59
+ end
60
+ gem_templates = File.join(File.dirname(__FILE__), "templates")
61
+ full = File.join(gem_templates, template_path)
62
+ return full if File.exist?(full)
63
+ nil
64
+ end
65
+
66
+ def default_error_html(code)
67
+ messages = { 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error" }
68
+ msg = messages[code] || "Error"
69
+ "<!DOCTYPE html><html><head><title>#{code} #{msg}</title></head>" \
70
+ "<body style='font-family:sans-serif;text-align:center;padding:50px;'>" \
71
+ "<h1>#{code}</h1><p>#{msg}</p><hr>" \
72
+ "<p style='color:#999;'>Tina4 Ruby v#{Tina4::VERSION}</p></body></html>"
73
+ end
74
+ end
75
+
76
+ class TwigEngine
77
+ FILTERS = {
78
+ "upper" => ->(v) { v.to_s.upcase },
79
+ "lower" => ->(v) { v.to_s.downcase },
80
+ "capitalize" => ->(v) { v.to_s.capitalize },
81
+ "title" => ->(v) { v.to_s.split.map(&:capitalize).join(" ") },
82
+ "trim" => ->(v) { v.to_s.strip },
83
+ "length" => ->(v) { v.respond_to?(:length) ? v.length : v.to_s.length },
84
+ "reverse" => ->(v) { v.respond_to?(:reverse) ? v.reverse : v.to_s.reverse },
85
+ "first" => ->(v) { v.respond_to?(:first) ? v.first : v.to_s[0] },
86
+ "last" => ->(v) { v.respond_to?(:last) ? v.last : v.to_s[-1] },
87
+ "join" => ->(v, sep) { v.respond_to?(:join) ? v.join(sep || ", ") : v.to_s },
88
+ "default" => ->(v, d) { (v.nil? || v.to_s.empty?) ? d : v },
89
+ "escape" => ->(v) { TwigEngine.escape_html(v.to_s) },
90
+ "e" => ->(v) { TwigEngine.escape_html(v.to_s) },
91
+ "nl2br" => ->(v) { v.to_s.gsub("\n", "<br>") },
92
+ "number_format" => ->(v, d) { format("%.#{d || 0}f", v.to_f) },
93
+ "raw" => ->(v) { v },
94
+ "striptags" => ->(v) { v.to_s.gsub(/<[^>]+>/, "") },
95
+ "sort" => ->(v) { v.respond_to?(:sort) ? v.sort : v },
96
+ "keys" => ->(v) { v.respond_to?(:keys) ? v.keys : [] },
97
+ "values" => ->(v) { v.respond_to?(:values) ? v.values : [v] },
98
+ "abs" => ->(v) { v.to_f.abs },
99
+ "round" => ->(v, p) { v.to_f.round(p&.to_i || 0) },
100
+ "url_encode" => ->(v) { URI.encode_www_form_component(v.to_s) },
101
+ "json_encode" => ->(v) { JSON.generate(v) rescue v.to_s },
102
+ "slice" => ->(v, s, e) { v.to_s[(s.to_i)..(e ? e.to_i : -1)] },
103
+ "merge" => ->(v, o) { v.respond_to?(:merge) ? v.merge(o || {}) : v },
104
+ "batch" => ->(v, s) { v.respond_to?(:each_slice) ? v.each_slice(s.to_i).to_a : [v] },
105
+ "date" => ->(v, fmt) { TwigEngine.format_date(v, fmt) }
106
+ }.freeze
107
+
108
+ def initialize(context = {}, base_dir = nil)
109
+ @context = context
110
+ @base_dir = base_dir || Dir.pwd
111
+ @blocks = {}
112
+ @parent_template = nil
113
+ end
114
+
115
+ def render(content)
116
+ content = process_extends(content)
117
+ content = process_blocks(content)
118
+ content = process_includes(content)
119
+ content = process_for_loops(content)
120
+ content = process_conditionals(content)
121
+ content = process_set(content)
122
+ content = process_expressions(content)
123
+ content = content.gsub(/\{%.*?%\}/m, "")
124
+ content = content.gsub(/\{#.*?#\}/m, "")
125
+ content
126
+ end
127
+
128
+ def self.escape_html(str)
129
+ str.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
130
+ .gsub('"', "&quot;").gsub("'", "&#39;")
131
+ end
132
+
133
+ def self.format_date(value, fmt)
134
+ require "date"
135
+ d = value.is_a?(String) ? DateTime.parse(value) : value
136
+ d.respond_to?(:strftime) ? d.strftime(fmt || "%Y-%m-%d") : value.to_s
137
+ rescue
138
+ value.to_s
139
+ end
140
+
141
+ private
142
+
143
+ def process_extends(content)
144
+ if content =~ /\{%\s*extends\s+["'](.+?)["']\s*%\}/
145
+ parent_path = Regexp.last_match(1)
146
+ full_parent = resolve_template(parent_path)
147
+ if full_parent && File.exist?(full_parent)
148
+ @parent_template = File.read(full_parent)
149
+ content.scan(/\{%\s*block\s+(\w+)\s*%\}(.*?)\{%\s*endblock\s*%\}/m) do |name, body|
150
+ @blocks[name] = body
151
+ end
152
+ content = @parent_template
153
+ end
154
+ end
155
+ content
156
+ end
157
+
158
+ def process_blocks(content)
159
+ content.gsub(/\{%\s*block\s+(\w+)\s*%\}(.*?)\{%\s*endblock\s*%\}/m) do
160
+ name = Regexp.last_match(1)
161
+ default_body = Regexp.last_match(2)
162
+ @blocks[name] || default_body
163
+ end
164
+ end
165
+
166
+ def process_includes(content)
167
+ content.gsub(/\{%\s*include\s+["'](.+?)["'](?:\s+with\s+(.+?))?\s*%\}/) do
168
+ inc_path = Regexp.last_match(1)
169
+ full_path = resolve_template(inc_path)
170
+ if full_path && File.exist?(full_path)
171
+ inc_content = File.read(full_path)
172
+ TwigEngine.new(@context.dup, File.dirname(full_path)).render(inc_content)
173
+ else
174
+ "<!-- include not found: #{inc_path} -->"
175
+ end
176
+ end
177
+ end
178
+
179
+ def process_for_loops(content)
180
+ max_depth = 10
181
+ depth = 0
182
+ while content =~ /\{%\s*for\s+/ && depth < max_depth
183
+ content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+?)\s*%\}(.*?)\{%\s*endfor\s*%\}/m) do
184
+ key_or_val = Regexp.last_match(1)
185
+ val_name = Regexp.last_match(2)
186
+ collection_expr = Regexp.last_match(3)
187
+ body = Regexp.last_match(4)
188
+ collection = evaluate_expression(collection_expr)
189
+ output = ""
190
+ items = case collection
191
+ when Array then collection
192
+ when Hash then collection.to_a
193
+ when Range then collection.to_a
194
+ when Integer then (0...collection).to_a
195
+ else []
196
+ end
197
+ items.each_with_index do |item, index|
198
+ loop_context = @context.dup
199
+ loop_context["loop"] = {
200
+ "index" => index + 1, "index0" => index,
201
+ "first" => index == 0, "last" => index == items.length - 1,
202
+ "length" => items.length,
203
+ "revindex" => items.length - index,
204
+ "revindex0" => items.length - index - 1
205
+ }
206
+ if val_name
207
+ loop_context[key_or_val] = item.is_a?(Array) ? item[0] : index
208
+ loop_context[val_name] = item.is_a?(Array) ? item[1] : item
209
+ else
210
+ loop_context[key_or_val] = item
211
+ end
212
+ sub_engine = TwigEngine.new(loop_context, @base_dir)
213
+ output += sub_engine.render(body)
214
+ end
215
+ output
216
+ end
217
+ depth += 1
218
+ end
219
+ content
220
+ end
221
+
222
+ def process_conditionals(content)
223
+ max_depth = 10
224
+ depth = 0
225
+ while content =~ /\{%\s*if\s+/ && depth < max_depth
226
+ content = content.gsub(
227
+ /\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}/m
228
+ ) do
229
+ condition = Regexp.last_match(1)
230
+ true_body = Regexp.last_match(2)
231
+ false_body = Regexp.last_match(3) || ""
232
+
233
+ if true_body =~ /\{%\s*elseif\s+/
234
+ segments = true_body.split(/\{%\s*elseif\s+/)
235
+ if evaluate_condition(condition)
236
+ segments[0]
237
+ else
238
+ resolved = false
239
+ result = ""
240
+ segments[1..].each do |seg|
241
+ next if resolved
242
+ if seg =~ /\A(.+?)\s*%\}(.*)\z/m
243
+ if evaluate_condition(Regexp.last_match(1))
244
+ result = Regexp.last_match(2)
245
+ resolved = true
246
+ end
247
+ end
248
+ end
249
+ resolved ? result : false_body
250
+ end
251
+ elsif evaluate_condition(condition)
252
+ true_body
253
+ else
254
+ false_body
255
+ end
256
+ end
257
+ depth += 1
258
+ end
259
+ content
260
+ end
261
+
262
+ def process_set(content)
263
+ content.gsub(/\{%\s*set\s+(\w+)\s*=\s*(.+?)\s*%\}/) do
264
+ var_name = Regexp.last_match(1)
265
+ expr = Regexp.last_match(2)
266
+ @context[var_name] = evaluate_expression(expr)
267
+ ""
268
+ end
269
+ end
270
+
271
+ def process_expressions(content)
272
+ content.gsub(/\{\{\s*(.+?)\s*\}\}/) do
273
+ expr = Regexp.last_match(1)
274
+ evaluate_piped_expression(expr).to_s
275
+ end
276
+ end
277
+
278
+ def evaluate_piped_expression(expr)
279
+ parts = expr.split("|").map(&:strip)
280
+ value = evaluate_expression(parts[0])
281
+ parts[1..].each do |filter_expr|
282
+ if filter_expr =~ /\A(\w+)(?:\((.+?)\))?\z/
283
+ filter_name = Regexp.last_match(1)
284
+ args_str = Regexp.last_match(2)
285
+ args = args_str ? parse_filter_args(args_str) : []
286
+ filter = FILTERS[filter_name]
287
+ value = args.empty? ? filter.call(value) : filter.call(value, *args) if filter
288
+ end
289
+ end
290
+ value
291
+ end
292
+
293
+ def parse_filter_args(args_str)
294
+ args = []
295
+ current = ""
296
+ in_quote = nil
297
+ args_str.each_char do |ch|
298
+ if in_quote
299
+ if ch == in_quote
300
+ current += ch
301
+ in_quote = nil
302
+ else
303
+ current += ch
304
+ end
305
+ elsif ch == '"' || ch == "'"
306
+ in_quote = ch
307
+ current += ch
308
+ elsif ch == ","
309
+ args << current.strip
310
+ current = ""
311
+ else
312
+ current += ch
313
+ end
314
+ end
315
+ args << current.strip unless current.strip.empty?
316
+
317
+ args.map do |arg|
318
+ if arg =~ /\A["'](.*)["']\z/
319
+ Regexp.last_match(1)
320
+ elsif arg =~ /\A\d+\z/
321
+ arg.to_i
322
+ elsif arg =~ /\A\d+\.\d+\z/
323
+ arg.to_f
324
+ else
325
+ evaluate_expression(arg)
326
+ end
327
+ end
328
+ end
329
+
330
+ def evaluate_expression(expr)
331
+ expr = expr.strip
332
+ return Regexp.last_match(1) if expr =~ /\A"([^"]*)"\z/ || expr =~ /\A'([^']*)'\z/
333
+ return expr.to_i if expr =~ /\A-?\d+\z/
334
+ return expr.to_f if expr =~ /\A-?\d+\.\d+\z/
335
+ return true if expr == "true"
336
+ return false if expr == "false"
337
+ return nil if expr == "null" || expr == "none" || expr == "nil"
338
+ if expr =~ /\A\[(.+)\]\z/
339
+ return Regexp.last_match(1).split(",").map { |i| evaluate_expression(i.strip) }
340
+ end
341
+ if expr =~ /\A(\d+)\.\.(\d+)\z/
342
+ return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i)
343
+ end
344
+ if expr.include?("~")
345
+ parts = expr.split("~").map { |p| evaluate_expression(p.strip) }
346
+ return parts.map(&:to_s).join
347
+ end
348
+ if expr =~ /\A(.+?)\s*(\+|-|\*|\/|%)\s*(.+)\z/
349
+ left = evaluate_expression(Regexp.last_match(1))
350
+ op = Regexp.last_match(2)
351
+ right = evaluate_expression(Regexp.last_match(3))
352
+ return apply_math(left, op, right)
353
+ end
354
+ resolve_variable(expr)
355
+ end
356
+
357
+ def resolve_variable(expr)
358
+ parts = expr.split(".")
359
+ value = @context
360
+ parts.each do |part|
361
+ if part =~ /\A(\w+)\[(.+?)\]\z/
362
+ base = Regexp.last_match(1)
363
+ index = Regexp.last_match(2)
364
+ value = access_value(value, base)
365
+ if index =~ /\A\d+\z/
366
+ value = value[index.to_i] if value.respond_to?(:[])
367
+ else
368
+ index = index.gsub(/["']/, "")
369
+ value = access_value(value, index)
370
+ end
371
+ else
372
+ value = access_value(value, part)
373
+ end
374
+ return nil if value.nil?
375
+ end
376
+ value
377
+ end
378
+
379
+ def access_value(obj, key)
380
+ return nil if obj.nil?
381
+ if obj.is_a?(Hash)
382
+ obj[key] || obj[key.to_sym] || obj[key.to_s]
383
+ elsif obj.respond_to?(key.to_sym)
384
+ obj.send(key.to_sym)
385
+ elsif obj.respond_to?(:[])
386
+ obj[key] rescue nil
387
+ end
388
+ end
389
+
390
+ def evaluate_condition(expr)
391
+ expr = expr.strip
392
+ return !evaluate_condition(Regexp.last_match(1)) if expr =~ /\Anot\s+(.+)\z/
393
+ if expr.include?(" and ")
394
+ return expr.split(/\s+and\s+/).all? { |p| evaluate_condition(p) }
395
+ end
396
+ if expr.include?(" or ")
397
+ return expr.split(/\s+or\s+/).any? { |p| evaluate_condition(p) }
398
+ end
399
+ if expr =~ /\A(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)\z/
400
+ left = evaluate_expression(Regexp.last_match(1).strip)
401
+ op = Regexp.last_match(2).strip
402
+ right = evaluate_expression(Regexp.last_match(3).strip)
403
+ return compare(left, op, right)
404
+ end
405
+ if expr =~ /\A(.+?)\s+in\s+(.+)\z/
406
+ needle = evaluate_expression(Regexp.last_match(1).strip)
407
+ haystack = evaluate_expression(Regexp.last_match(2).strip)
408
+ return haystack.respond_to?(:include?) ? haystack.include?(needle) : false
409
+ end
410
+ if expr =~ /\A(.+?)\s+is\s+defined\z/
411
+ return !evaluate_expression(Regexp.last_match(1).strip).nil?
412
+ end
413
+ if expr =~ /\A(.+?)\s+is\s+empty\z/
414
+ val = evaluate_expression(Regexp.last_match(1).strip)
415
+ return val.nil? || (val.respond_to?(:empty?) && val.empty?)
416
+ end
417
+ truthy?(evaluate_expression(expr))
418
+ end
419
+
420
+ def compare(left, op, right)
421
+ case op
422
+ when "==" then left == right
423
+ when "!=" then left != right
424
+ when ">" then left.to_f > right.to_f
425
+ when "<" then left.to_f < right.to_f
426
+ when ">=" then left.to_f >= right.to_f
427
+ when "<=" then left.to_f <= right.to_f
428
+ else false
429
+ end
430
+ end
431
+
432
+ def apply_math(left, op, right)
433
+ l = left.to_f
434
+ r = right.to_f
435
+ case op
436
+ when "+" then l + r
437
+ when "-" then l - r
438
+ when "*" then l * r
439
+ when "/" then r != 0 ? l / r : 0
440
+ when "%" then l % r
441
+ else 0
442
+ end
443
+ end
444
+
445
+ def truthy?(val)
446
+ return false if val.nil? || val == false || val == 0 || val == ""
447
+ return false if val.respond_to?(:empty?) && val.empty?
448
+ true
449
+ end
450
+
451
+ def resolve_template(path)
452
+ full = File.join(@base_dir, path)
453
+ return full if File.exist?(full)
454
+ Tina4::Template::TEMPLATE_DIRS.each do |dir|
455
+ candidate = File.join(Dir.pwd, dir, path)
456
+ return candidate if File.exist?(candidate)
457
+ end
458
+ nil
459
+ end
460
+ end
461
+
462
+ class ErbEngine
463
+ def self.render(content, context)
464
+ require "erb"
465
+ binding_obj = create_binding(context)
466
+ ERB.new(content, trim_mode: "-").result(binding_obj)
467
+ end
468
+
469
+ def self.create_binding(context)
470
+ b = binding
471
+ context.each do |key, value|
472
+ b.local_variable_set(key.to_sym, value)
473
+ end
474
+ b
475
+ end
476
+ end
477
+ end
478
+ end
@@ -0,0 +1,26 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}Tina4 Ruby{% endblock %}</title>
7
+ <link rel="stylesheet" href="/css/tina4.min.css">
8
+ {% block head %}{% endblock %}
9
+ </head>
10
+ <body>
11
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
12
+ <div class="container">
13
+ <a class="navbar-brand" href="/">Tina4</a>
14
+ </div>
15
+ </nav>
16
+ <main class="container mt-4">
17
+ {% block content %}{% endblock %}
18
+ </main>
19
+ <footer class="container mt-4 py-3 text-center text-muted border-top">
20
+ <p>Powered by Tina4 Ruby v{{ tina4_version }}</p>
21
+ </footer>
22
+ <script src="/js/tina4.js"></script>
23
+ <script src="/js/tina4helper.js"></script>
24
+ {% block scripts %}{% endblock %}
25
+ </body>
26
+ </html>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>403 Forbidden</title>
7
+ <style>
8
+ body { font-family: -apple-system, sans-serif; text-align: center; padding: 80px 20px; background: #f8f9fa; }
9
+ h1 { font-size: 6rem; color: #dc3545; margin: 0; }
10
+ p { font-size: 1.2rem; color: #6c757d; }
11
+ a { color: #0d6efd; text-decoration: none; }
12
+ .container { max-width: 600px; margin: 0 auto; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <div class="container">
17
+ <h1>403</h1>
18
+ <p>Forbidden. You do not have permission to access this resource.</p>
19
+ <p><a href="/">Go Home</a></p>
20
+ </div>
21
+ </body>
22
+ </html>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>404 Not Found</title>
7
+ <style>
8
+ body { font-family: -apple-system, sans-serif; text-align: center; padding: 80px 20px; background: #f8f9fa; }
9
+ h1 { font-size: 6rem; color: #ffc107; margin: 0; }
10
+ p { font-size: 1.2rem; color: #6c757d; }
11
+ a { color: #0d6efd; text-decoration: none; }
12
+ .container { max-width: 600px; margin: 0 auto; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <div class="container">
17
+ <h1>404</h1>
18
+ <p>Page not found. The requested resource could not be located.</p>
19
+ <p><a href="/">Go Home</a></p>
20
+ </div>
21
+ </body>
22
+ </html>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>500 Internal Server Error</title>
7
+ <style>
8
+ body { font-family: -apple-system, sans-serif; text-align: center; padding: 80px 20px; background: #f8f9fa; }
9
+ h1 { font-size: 6rem; color: #dc3545; margin: 0; }
10
+ p { font-size: 1.2rem; color: #6c757d; }
11
+ a { color: #0d6efd; text-decoration: none; }
12
+ .container { max-width: 600px; margin: 0 auto; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <div class="container">
17
+ <h1>500</h1>
18
+ <p>Internal Server Error. Something went wrong on our end.</p>
19
+ <p><a href="/">Go Home</a></p>
20
+ </div>
21
+ </body>
22
+ </html>