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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +360 -559
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +242 -77
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +43 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1336 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +134 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +57 -21
- metadata +51 -19
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
# Cross-engine SQL translator.
|
|
7
|
+
#
|
|
8
|
+
# Each database adapter calls the rules it needs. Rules are composable
|
|
9
|
+
# and stateless -- just string transforms.
|
|
10
|
+
#
|
|
11
|
+
# Also includes query caching with TTL support.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# translated = Tina4::SQLTranslator.limit_to_rows("SELECT * FROM users LIMIT 10 OFFSET 5")
|
|
15
|
+
# # => "SELECT * FROM users ROWS 6 TO 15"
|
|
16
|
+
#
|
|
17
|
+
class SQLTranslator
|
|
18
|
+
class << self
|
|
19
|
+
# Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
|
|
20
|
+
#
|
|
21
|
+
# LIMIT 10 OFFSET 5 => ROWS 6 TO 15
|
|
22
|
+
# LIMIT 10 => ROWS 1 TO 10
|
|
23
|
+
#
|
|
24
|
+
# @param sql [String]
|
|
25
|
+
# @return [String]
|
|
26
|
+
def limit_to_rows(sql)
|
|
27
|
+
# Try LIMIT X OFFSET Y first
|
|
28
|
+
if (m = sql.match(/\bLIMIT\s+(\d+)\s+OFFSET\s+(\d+)\s*$/i))
|
|
29
|
+
limit = m[1].to_i
|
|
30
|
+
offset = m[2].to_i
|
|
31
|
+
start_row = offset + 1
|
|
32
|
+
end_row = offset + limit
|
|
33
|
+
return sql[0...m.begin(0)] + "ROWS #{start_row} TO #{end_row}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Then try LIMIT X only
|
|
37
|
+
if (m = sql.match(/\bLIMIT\s+(\d+)\s*$/i))
|
|
38
|
+
limit = m[1].to_i
|
|
39
|
+
return sql[0...m.begin(0)] + "ROWS 1 TO #{limit}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sql
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Convert LIMIT to MSSQL TOP syntax.
|
|
46
|
+
#
|
|
47
|
+
# SELECT ... LIMIT 10 => SELECT TOP 10 ...
|
|
48
|
+
# OFFSET queries are left unchanged (not supported by TOP).
|
|
49
|
+
#
|
|
50
|
+
# @param sql [String]
|
|
51
|
+
# @return [String]
|
|
52
|
+
def limit_to_top(sql)
|
|
53
|
+
if (m = sql.match(/\bLIMIT\s+(\d+)\s*$/i)) && !sql.match?(/\bOFFSET\b/i)
|
|
54
|
+
limit = m[1].to_i
|
|
55
|
+
body = sql[0...m.begin(0)].strip
|
|
56
|
+
return body.sub(/^(SELECT)\b/i, "\\1 TOP #{limit}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sql
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Convert || concatenation to CONCAT() for MySQL/MSSQL.
|
|
63
|
+
#
|
|
64
|
+
# 'a' || 'b' || 'c' => CONCAT('a', 'b', 'c')
|
|
65
|
+
#
|
|
66
|
+
# @param sql [String]
|
|
67
|
+
# @return [String]
|
|
68
|
+
def concat_pipes_to_func(sql)
|
|
69
|
+
return sql unless sql.include?("||")
|
|
70
|
+
|
|
71
|
+
parts = sql.split("||")
|
|
72
|
+
if parts.length > 1
|
|
73
|
+
"CONCAT(#{parts.map(&:strip).join(', ')})"
|
|
74
|
+
else
|
|
75
|
+
sql
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convert TRUE/FALSE to 1/0 for engines without boolean type.
|
|
80
|
+
#
|
|
81
|
+
# @param sql [String]
|
|
82
|
+
# @return [String]
|
|
83
|
+
def boolean_to_int(sql)
|
|
84
|
+
sql.gsub(/\bTRUE\b/i, "1").gsub(/\bFALSE\b/i, "0")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Convert ILIKE to LOWER() LIKE LOWER() for engines without ILIKE.
|
|
88
|
+
#
|
|
89
|
+
# @param sql [String]
|
|
90
|
+
# @return [String]
|
|
91
|
+
def ilike_to_like(sql)
|
|
92
|
+
sql.gsub(/(\S+)\s+ILIKE\s+(\S+)/i) do
|
|
93
|
+
col = ::Regexp.last_match(1).strip
|
|
94
|
+
val = ::Regexp.last_match(2).strip
|
|
95
|
+
"LOWER(#{col}) LIKE LOWER(#{val})"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Translate AUTOINCREMENT across engines in DDL.
|
|
100
|
+
#
|
|
101
|
+
# @param sql [String]
|
|
102
|
+
# @param engine [String] one of: mysql, postgresql, mssql, firebird, sqlite
|
|
103
|
+
# @return [String]
|
|
104
|
+
def auto_increment_syntax(sql, engine)
|
|
105
|
+
case engine
|
|
106
|
+
when "mysql"
|
|
107
|
+
sql.gsub("AUTOINCREMENT", "AUTO_INCREMENT")
|
|
108
|
+
when "postgresql"
|
|
109
|
+
sql.gsub(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/i, "SERIAL PRIMARY KEY")
|
|
110
|
+
when "mssql"
|
|
111
|
+
sql.gsub(/AUTOINCREMENT/i, "IDENTITY(1,1)")
|
|
112
|
+
when "firebird"
|
|
113
|
+
sql.gsub(/\s*AUTOINCREMENT\b/i, "")
|
|
114
|
+
else
|
|
115
|
+
sql
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Convert ? placeholders to engine-specific style.
|
|
120
|
+
#
|
|
121
|
+
# ? => %s (MySQL, PostgreSQL)
|
|
122
|
+
# ? => :1, :2 (Oracle, Firebird)
|
|
123
|
+
#
|
|
124
|
+
# @param sql [String]
|
|
125
|
+
# @param style [String] target placeholder style: "%s" or ":"
|
|
126
|
+
# @return [String]
|
|
127
|
+
def placeholder_style(sql, style)
|
|
128
|
+
case style
|
|
129
|
+
when "%s"
|
|
130
|
+
sql.gsub("?", "%s")
|
|
131
|
+
when ":"
|
|
132
|
+
count = 0
|
|
133
|
+
sql.chars.map do |ch|
|
|
134
|
+
if ch == "?"
|
|
135
|
+
count += 1
|
|
136
|
+
":#{count}"
|
|
137
|
+
else
|
|
138
|
+
ch
|
|
139
|
+
end
|
|
140
|
+
end.join
|
|
141
|
+
else
|
|
142
|
+
sql
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Generate a cache key for a query and its parameters.
|
|
147
|
+
#
|
|
148
|
+
# @param sql [String]
|
|
149
|
+
# @param params [Array, nil]
|
|
150
|
+
# @return [String]
|
|
151
|
+
def query_key(sql, params = nil)
|
|
152
|
+
raw = params ? "#{sql}|#{params.inspect}" : sql
|
|
153
|
+
"query:#{Digest::SHA256.hexdigest(raw)}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# In-memory cache with TTL support for query results.
|
|
159
|
+
#
|
|
160
|
+
# Usage:
|
|
161
|
+
# cache = Tina4::QueryCache.new(default_ttl: 60, max_size: 1000)
|
|
162
|
+
# cache.set("key", "value", ttl: 30)
|
|
163
|
+
# cache.get("key") # => "value"
|
|
164
|
+
#
|
|
165
|
+
class QueryCache
|
|
166
|
+
CacheEntry = Struct.new(:value, :expires_at, :tags)
|
|
167
|
+
|
|
168
|
+
# @param default_ttl [Integer] default TTL in seconds (default: 300)
|
|
169
|
+
# @param max_size [Integer] maximum number of cache entries (default: 1000)
|
|
170
|
+
def initialize(default_ttl: 300, max_size: 1000)
|
|
171
|
+
@default_ttl = default_ttl
|
|
172
|
+
@max_size = max_size
|
|
173
|
+
@store = {}
|
|
174
|
+
@mutex = Mutex.new
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Store a value with optional TTL and tags.
|
|
178
|
+
#
|
|
179
|
+
# @param key [String]
|
|
180
|
+
# @param value [Object]
|
|
181
|
+
# @param ttl [Integer, nil] TTL in seconds (nil uses default)
|
|
182
|
+
# @param tags [Array<String>] optional tags for grouped invalidation
|
|
183
|
+
def set(key, value, ttl: nil, tags: [])
|
|
184
|
+
ttl ||= @default_ttl
|
|
185
|
+
expires_at = Time.now.to_f + ttl
|
|
186
|
+
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
# Evict oldest if at capacity
|
|
189
|
+
if @store.size >= @max_size && !@store.key?(key)
|
|
190
|
+
oldest_key = @store.keys.first
|
|
191
|
+
@store.delete(oldest_key)
|
|
192
|
+
end
|
|
193
|
+
@store[key] = CacheEntry.new(value, expires_at, tags)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Retrieve a cached value. Returns nil if expired or missing.
|
|
198
|
+
#
|
|
199
|
+
# @param key [String]
|
|
200
|
+
# @param default [Object] value to return if key is missing
|
|
201
|
+
# @return [Object, nil]
|
|
202
|
+
def get(key, default = nil)
|
|
203
|
+
@mutex.synchronize do
|
|
204
|
+
entry = @store[key]
|
|
205
|
+
return default unless entry
|
|
206
|
+
|
|
207
|
+
if Time.now.to_f > entry.expires_at
|
|
208
|
+
@store.delete(key)
|
|
209
|
+
return default
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
entry.value
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Check if a key exists and is not expired.
|
|
217
|
+
#
|
|
218
|
+
# @param key [String]
|
|
219
|
+
# @return [Boolean]
|
|
220
|
+
def has?(key)
|
|
221
|
+
@mutex.synchronize do
|
|
222
|
+
entry = @store[key]
|
|
223
|
+
return false unless entry
|
|
224
|
+
|
|
225
|
+
if Time.now.to_f > entry.expires_at
|
|
226
|
+
@store.delete(key)
|
|
227
|
+
return false
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
true
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Delete a key from the cache.
|
|
235
|
+
#
|
|
236
|
+
# @param key [String]
|
|
237
|
+
# @return [Boolean] true if the key was present
|
|
238
|
+
def delete(key)
|
|
239
|
+
@mutex.synchronize do
|
|
240
|
+
!@store.delete(key).nil?
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Clear all entries from the cache.
|
|
245
|
+
def clear
|
|
246
|
+
@mutex.synchronize { @store.clear }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Clear all entries with a given tag.
|
|
250
|
+
#
|
|
251
|
+
# @param tag [String]
|
|
252
|
+
# @return [Integer] number of entries removed
|
|
253
|
+
def clear_tag(tag)
|
|
254
|
+
@mutex.synchronize do
|
|
255
|
+
keys_to_remove = @store.select { |_k, v| v.tags.include?(tag) }.keys
|
|
256
|
+
keys_to_remove.each { |k| @store.delete(k) }
|
|
257
|
+
keys_to_remove.size
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Remove all expired entries.
|
|
262
|
+
#
|
|
263
|
+
# @return [Integer] number of entries removed
|
|
264
|
+
def sweep
|
|
265
|
+
@mutex.synchronize do
|
|
266
|
+
now = Time.now.to_f
|
|
267
|
+
keys_to_remove = @store.select { |_k, v| now > v.expires_at }.keys
|
|
268
|
+
keys_to_remove.each { |k| @store.delete(k) }
|
|
269
|
+
keys_to_remove.size
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Fetch from cache, or compute and store.
|
|
274
|
+
#
|
|
275
|
+
# @param key [String]
|
|
276
|
+
# @param ttl [Integer] TTL in seconds
|
|
277
|
+
# @param block [Proc] factory to compute the value if not cached
|
|
278
|
+
# @return [Object]
|
|
279
|
+
def remember(key, ttl, &block)
|
|
280
|
+
cached = get(key)
|
|
281
|
+
return cached unless cached.nil?
|
|
282
|
+
|
|
283
|
+
value = block.call
|
|
284
|
+
set(key, value, ttl: ttl)
|
|
285
|
+
value
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Current number of entries in the cache.
|
|
289
|
+
#
|
|
290
|
+
# @return [Integer]
|
|
291
|
+
def size
|
|
292
|
+
@mutex.synchronize { @store.size }
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
data/lib/tina4/template.rb
CHANGED
|
@@ -33,16 +33,18 @@ module Tina4
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def render_error(code)
|
|
36
|
+
def render_error(code, data = {})
|
|
37
37
|
error_dirs = TEMPLATE_DIRS.map { |d| File.join(Dir.pwd, d, "errors") }
|
|
38
38
|
error_dirs << File.join(File.dirname(__FILE__), "templates", "errors")
|
|
39
39
|
|
|
40
|
+
context = { "code" => code }.merge(data.transform_keys(&:to_s))
|
|
41
|
+
|
|
40
42
|
error_dirs.each do |dir|
|
|
41
43
|
%w[.twig .html .erb].each do |ext|
|
|
42
44
|
path = File.join(dir, "#{code}#{ext}")
|
|
43
45
|
if File.exist?(path)
|
|
44
46
|
content = File.read(path)
|
|
45
|
-
return TwigEngine.new(
|
|
47
|
+
return TwigEngine.new(context, dir).render(content)
|
|
46
48
|
end
|
|
47
49
|
end
|
|
48
50
|
end
|
|
@@ -66,10 +68,38 @@ module Tina4
|
|
|
66
68
|
def default_error_html(code)
|
|
67
69
|
messages = { 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error" }
|
|
68
70
|
msg = messages[code] || "Error"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
colors = { 403 => "#f59e0b", 404 => "#3b82f6", 500 => "#ef4444" }
|
|
72
|
+
color = colors[code] || "#ef4444"
|
|
73
|
+
<<~HTML
|
|
74
|
+
<!DOCTYPE html>
|
|
75
|
+
<html lang="en">
|
|
76
|
+
<head>
|
|
77
|
+
<meta charset="utf-8">
|
|
78
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
79
|
+
<title>#{code} — #{msg}</title>
|
|
80
|
+
<style>
|
|
81
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
82
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
83
|
+
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
84
|
+
.error-code { font-size: 8rem; font-weight: 900; color: #{color}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
|
|
85
|
+
.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
|
|
86
|
+
.error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
87
|
+
.error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
|
|
88
|
+
.error-home:hover { opacity: 0.9; }
|
|
89
|
+
.logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
|
|
90
|
+
</style>
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
<div class="error-card">
|
|
94
|
+
<div class="logo">T4</div>
|
|
95
|
+
<div class="error-code">#{code}</div>
|
|
96
|
+
<div class="error-title">#{msg}</div>
|
|
97
|
+
<div class="error-msg">Something went wrong while processing your request.</div>
|
|
98
|
+
<a href="/" class="error-home">Go Home</a>
|
|
99
|
+
</div>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
HTML
|
|
73
103
|
end
|
|
74
104
|
end
|
|
75
105
|
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
<footer class="container mt-4 py-3 text-center text-muted border-top">
|
|
20
20
|
<p>Powered by Tina4 Ruby v{{ tina4_version }}</p>
|
|
21
21
|
</footer>
|
|
22
|
-
<script src="/js/tina4.js"></script>
|
|
23
|
-
<script src="/js/
|
|
22
|
+
<script src="/js/tina4.min.js"></script>
|
|
23
|
+
<script src="/js/frond.min.js"></script>
|
|
24
24
|
{% block scripts %}{% endblock %}
|
|
25
25
|
</body>
|
|
26
26
|
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends "errors/base.twig" %}
|
|
2
|
+
{% block title %}Redirecting…{% endblock %}
|
|
3
|
+
{% block meta %}<meta http-equiv="refresh" content="0;url={{ redirect_url }}">{% endblock %}
|
|
4
|
+
{% block extra_styles %}
|
|
5
|
+
.spinner { display: inline-block; width: 1.5rem; height: 1.5rem; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 1rem; }
|
|
6
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
7
|
+
{% endblock %}
|
|
8
|
+
{% block content %}
|
|
9
|
+
<div class="spinner"></div>
|
|
10
|
+
<div class="error-title">Redirecting…</div>
|
|
11
|
+
<div class="error-msg">You are being redirected to a new location.</div>
|
|
12
|
+
<div class="error-path" style="color:var(--primary)">{{ redirect_url }}</div>
|
|
13
|
+
<a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{% extends "errors/base.twig" %}
|
|
2
|
+
{% block title %}401 — Unauthorized{% endblock %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="error-code" style="color:var(--danger)">401</div>
|
|
5
|
+
<div class="error-title">Unauthorized</div>
|
|
6
|
+
<div class="error-msg">You need to sign in to access this resource.</div>
|
|
7
|
+
<div class="error-path" style="color:var(--danger)">{{ path }}</div>
|
|
8
|
+
<a href="/" class="error-home">Go Home</a>
|
|
9
|
+
{% endblock %}
|
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>403 — Forbidden</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
10
|
+
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
11
|
+
.error-code { font-size: 8rem; font-weight: 900; color: #f59e0b; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
|
|
12
|
+
.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
|
|
13
|
+
.error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
14
|
+
.error-path { font-family: 'SF Mono', monospace; background: #0f172a; color: #f59e0b; padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; margin-bottom: 1.5rem; display: inline-block; }
|
|
15
|
+
.error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
|
|
16
|
+
.error-home:hover { opacity: 0.9; }
|
|
17
|
+
</style>
|
|
14
18
|
</head>
|
|
15
19
|
<body>
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
<
|
|
20
|
-
|
|
20
|
+
<div class="error-card">
|
|
21
|
+
<div class="error-code">403</div>
|
|
22
|
+
<div class="error-title">Forbidden</div>
|
|
23
|
+
<div class="error-msg">You don't have permission to access this resource.</div>
|
|
24
|
+
<div class="error-path">{{ path }}</div>
|
|
25
|
+
<br>
|
|
26
|
+
<a href="/" class="error-home">Go Home</a>
|
|
27
|
+
</div>
|
|
21
28
|
</body>
|
|
22
29
|
</html>
|
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>404 — Not Found</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
10
|
+
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
11
|
+
.error-code { font-size: 8rem; font-weight: 900; color: #3b82f6; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
|
|
12
|
+
.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
|
|
13
|
+
.error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
14
|
+
.error-path { font-family: 'SF Mono', monospace; background: #0f172a; color: #3b82f6; padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; margin-bottom: 1.5rem; display: inline-block; }
|
|
15
|
+
.error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
|
|
16
|
+
.error-home:hover { opacity: 0.9; }
|
|
17
|
+
</style>
|
|
14
18
|
</head>
|
|
15
19
|
<body>
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
<
|
|
20
|
-
|
|
20
|
+
<div class="error-card">
|
|
21
|
+
<div class="error-code">404</div>
|
|
22
|
+
<div class="error-title">Page Not Found</div>
|
|
23
|
+
<div class="error-msg">The page you're looking for doesn't exist or has been moved. Check the URL and try again.</div>
|
|
24
|
+
<div class="error-path">{{ path }}</div>
|
|
25
|
+
<br>
|
|
26
|
+
<a href="/" class="error-home">Go Home</a>
|
|
27
|
+
</div>
|
|
21
28
|
</body>
|
|
22
29
|
</html>
|
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>500 — Server Error</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
10
|
+
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: left; max-width: 700px; width: 90%; }
|
|
11
|
+
.error-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
|
|
12
|
+
.error-code { font-size: 3rem; font-weight: 900; color: #ef4444; opacity: 0.7; }
|
|
13
|
+
.error-title { font-size: 1.3rem; font-weight: 700; }
|
|
14
|
+
.error-msg { color: #94a3b8; font-size: 0.95rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
15
|
+
.error-trace { background: #0f172a; border: 1px solid #334155; border-radius: 0.5rem; padding: 1rem; font-family: 'SF Mono', monospace; font-size: 0.8rem; line-height: 1.5; overflow-x: auto; max-height: 400px; overflow-y: auto; white-space: pre-wrap; color: #ef4444; margin-bottom: 1.5rem; }
|
|
16
|
+
.error-footer { display: flex; justify-content: space-between; align-items: center; }
|
|
17
|
+
.error-hint { color: #64748b; font-size: 0.75rem; }
|
|
18
|
+
.error-id { color: #64748b; font-family: 'SF Mono', monospace; font-size: 0.75rem; }
|
|
19
|
+
.error-home { display: inline-block; padding: 0.5rem 1.5rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.85rem; font-weight: 600; }
|
|
20
|
+
.error-home:hover { opacity: 0.9; }
|
|
21
|
+
</style>
|
|
14
22
|
</head>
|
|
15
23
|
<body>
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
<div class="error-card">
|
|
25
|
+
<div class="error-header">
|
|
26
|
+
<div class="error-code">500</div>
|
|
27
|
+
<div class="error-title">Server Error</div>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="error-msg">Something went wrong while processing your request.</div>
|
|
30
|
+
<pre class="error-trace">{{ error_message }}</pre>
|
|
31
|
+
<div class="error-footer">
|
|
32
|
+
<span class="error-hint">Fix the error and save to auto-reload</span>
|
|
33
|
+
<span class="error-id">{{ request_id }}</span>
|
|
34
|
+
<a href="/" class="error-home">Go Home</a>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
21
37
|
</body>
|
|
22
38
|
</html>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{% extends "errors/base.twig" %}
|
|
2
|
+
{% block title %}502 — Bad Gateway{% endblock %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="error-code" style="color:var(--danger)">502</div>
|
|
5
|
+
<div class="error-title">Bad Gateway</div>
|
|
6
|
+
<div class="error-msg">The upstream server returned an invalid response.</div>
|
|
7
|
+
<div class="error-path" style="color:var(--danger)">{{ path }}</div>
|
|
8
|
+
<a href="/" class="error-home">Go Home</a>
|
|
9
|
+
{% endblock %}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{% extends "errors/base.twig" %}
|
|
2
|
+
{% block title %}503 — Service Unavailable{% endblock %}
|
|
3
|
+
{% block extra_styles %}
|
|
4
|
+
.error-code { color: var(--warn); }
|
|
5
|
+
{% endblock %}
|
|
6
|
+
{% block content %}
|
|
7
|
+
<div class="error-code" style="color:var(--warn)">503</div>
|
|
8
|
+
<div class="error-title">Service Unavailable</div>
|
|
9
|
+
<div class="error-msg">The server is temporarily unavailable. Please try again shortly.</div>
|
|
10
|
+
<div class="error-path" style="color:var(--warn)">{{ path }}</div>
|
|
11
|
+
<a href="/" class="error-home">Go Home</a>
|
|
12
|
+
{% endblock %}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
{% block meta %}{% endblock %}
|
|
7
|
+
<title>{% block title %}Error{% endblock %}</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0f172a; --surface: #1e293b; --border: #334155;
|
|
11
|
+
--text: #e2e8f0; --muted: #94a3b8; --primary: #3b82f6;
|
|
12
|
+
--success: #22c55e; --danger: #ef4444; --warn: #f59e0b; --info: #06b6d4;
|
|
13
|
+
--radius: 1rem; --mono: 'SF Mono', ui-monospace, monospace;
|
|
14
|
+
--font: system-ui, -apple-system, sans-serif;
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
17
|
+
body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
18
|
+
.error-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 3rem; text-align: center; max-width: 480px; width: 90%; }
|
|
19
|
+
.error-code { font-size: 5rem; font-weight: 800; opacity: 0.3; line-height: 1; }
|
|
20
|
+
.error-title { font-size: 1.5rem; font-weight: 600; margin: 0.5rem 0; }
|
|
21
|
+
.error-msg { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
|
|
22
|
+
.error-path { font-family: var(--mono); background: var(--bg); padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; }
|
|
23
|
+
.error-home { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.5rem; background: var(--primary); color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.85rem; }
|
|
24
|
+
.error-home:hover { opacity: 0.9; }
|
|
25
|
+
.logo { width: 3rem; height: 3rem; margin-bottom: 1rem; opacity: 0.5; }
|
|
26
|
+
{% block extra_styles %}{% endblock %}
|
|
27
|
+
</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
{% block body %}
|
|
31
|
+
<div class="error-card">
|
|
32
|
+
<img src="/images/logo.svg" class="logo" alt="Tina4">
|
|
33
|
+
{% block content %}{% endblock %}
|
|
34
|
+
</div>
|
|
35
|
+
{% endblock %}
|
|
36
|
+
</body>
|
|
37
|
+
</html>
|
data/lib/tina4/version.rb
CHANGED