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/orm.rb CHANGED
@@ -7,32 +7,131 @@ module Tina4
7
7
 
8
8
  class << self
9
9
  def db
10
- Tina4.database
10
+ @db || Tina4.database
11
11
  end
12
12
 
13
- def find(id)
14
- pk = primary_key_field || :id
15
- result = db.fetch_one("SELECT * FROM #{table_name} WHERE #{pk} = ?", [id])
16
- return nil unless result
17
- from_hash(result)
13
+ # Per-model database binding
14
+ def db=(database)
15
+ @db = database
16
+ end
17
+
18
+ # Soft delete configuration
19
+ def soft_delete
20
+ @soft_delete || false
21
+ end
22
+
23
+ def soft_delete=(val)
24
+ @soft_delete = val
25
+ end
26
+
27
+ def soft_delete_field
28
+ @soft_delete_field || :is_deleted
29
+ end
30
+
31
+ def soft_delete_field=(val)
32
+ @soft_delete_field = val
33
+ end
34
+
35
+ # Field mapping: { 'db_column' => 'ruby_attribute' }
36
+ def field_mapping
37
+ @field_mapping || {}
38
+ end
39
+
40
+ def field_mapping=(map)
41
+ @field_mapping = map
42
+ end
43
+
44
+ # Relationship definitions
45
+ def relationship_definitions
46
+ @relationship_definitions ||= {}
47
+ end
48
+
49
+ # has_one :profile, class_name: "Profile", foreign_key: "user_id"
50
+ def has_one(name, class_name: nil, foreign_key: nil)
51
+ relationship_definitions[name] = {
52
+ type: :has_one,
53
+ class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
54
+ foreign_key: foreign_key
55
+ }
56
+
57
+ define_method(name) do
58
+ load_has_one(name)
59
+ end
60
+ end
61
+
62
+ # has_many :posts, class_name: "Post", foreign_key: "user_id"
63
+ def has_many(name, class_name: nil, foreign_key: nil)
64
+ relationship_definitions[name] = {
65
+ type: :has_many,
66
+ class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
67
+ foreign_key: foreign_key
68
+ }
69
+
70
+ define_method(name) do
71
+ load_has_many(name)
72
+ end
73
+ end
74
+
75
+ # belongs_to :user, class_name: "User", foreign_key: "user_id"
76
+ def belongs_to(name, class_name: nil, foreign_key: nil)
77
+ relationship_definitions[name] = {
78
+ type: :belongs_to,
79
+ class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
80
+ foreign_key: foreign_key || "#{name}_id"
81
+ }
82
+
83
+ define_method(name) do
84
+ load_belongs_to(name)
85
+ end
86
+ end
87
+
88
+ def find(id_or_filter = nil, filter = nil)
89
+ # find(id) — find by primary key
90
+ # find(filter_hash) — find by criteria
91
+ if id_or_filter.is_a?(Hash)
92
+ find_by_filter(id_or_filter)
93
+ elsif filter.is_a?(Hash)
94
+ find_by_filter(filter)
95
+ else
96
+ find_by_id(id_or_filter)
97
+ end
18
98
  end
19
99
 
20
100
  def where(conditions, params = [])
21
- sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
101
+ sql = "SELECT * FROM #{table_name}"
102
+ if soft_delete
103
+ sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
104
+ else
105
+ sql += " WHERE #{conditions}"
106
+ end
22
107
  results = db.fetch(sql, params)
23
108
  results.map { |row| from_hash(row) }
24
109
  end
25
110
 
26
- def all(limit: nil, skip: nil, order_by: nil)
111
+ def all(limit: nil, offset: nil, skip: nil, order_by: nil)
27
112
  sql = "SELECT * FROM #{table_name}"
113
+ if soft_delete
114
+ sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
115
+ end
28
116
  sql += " ORDER BY #{order_by}" if order_by
29
- results = db.fetch(sql, [], limit: limit, skip: skip)
117
+ effective_offset = offset || skip
118
+ results = db.fetch(sql, [], limit: limit, skip: effective_offset)
119
+ results.map { |row| from_hash(row) }
120
+ end
121
+
122
+ def select(sql, params = [], limit: nil, skip: nil)
123
+ results = db.fetch(sql, params, limit: limit, skip: skip)
30
124
  results.map { |row| from_hash(row) }
31
125
  end
32
126
 
33
127
  def count(conditions = nil, params = [])
34
128
  sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
35
- sql += " WHERE #{conditions}" if conditions
129
+ where_parts = []
130
+ if soft_delete
131
+ where_parts << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
132
+ end
133
+ where_parts << "(#{conditions})" if conditions
134
+ sql += " WHERE #{where_parts.join(' AND ')}" unless where_parts.empty?
36
135
  result = db.fetch_one(sql, params)
37
136
  result[:cnt].to_i
38
137
  end
@@ -43,28 +142,113 @@ module Tina4
43
142
  instance
44
143
  end
45
144
 
145
+ def find_or_fail(id)
146
+ result = find(id)
147
+ raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
148
+ result
149
+ end
150
+
151
+ def with_trashed(conditions = "1=1", params = [], limit: 20, skip: 0)
152
+ sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
153
+ results = db.fetch(sql, params, limit: limit, skip: skip)
154
+ results.map { |row| from_hash(row) }
155
+ end
156
+
157
+ def create_table
158
+ return true if db.table_exists?(table_name)
159
+
160
+ type_map = {
161
+ integer: "INTEGER",
162
+ string: "VARCHAR(255)",
163
+ text: "TEXT",
164
+ float: "REAL",
165
+ decimal: "REAL",
166
+ boolean: "INTEGER",
167
+ date: "DATE",
168
+ datetime: "DATETIME",
169
+ timestamp: "TIMESTAMP",
170
+ blob: "BLOB",
171
+ json: "TEXT"
172
+ }
173
+
174
+ col_defs = []
175
+ field_definitions.each do |name, opts|
176
+ sql_type = type_map[opts[:type]] || "TEXT"
177
+ if opts[:type] == :string && opts[:length]
178
+ sql_type = "VARCHAR(#{opts[:length]})"
179
+ end
180
+
181
+ parts = ["#{name} #{sql_type}"]
182
+ parts << "PRIMARY KEY" if opts[:primary_key]
183
+ parts << "AUTOINCREMENT" if opts[:auto_increment]
184
+ parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
185
+ if opts[:default] && !opts[:auto_increment]
186
+ default_val = opts[:default].is_a?(String) ? "'#{opts[:default]}'" : opts[:default]
187
+ parts << "DEFAULT #{default_val}"
188
+ end
189
+ col_defs << parts.join(" ")
190
+ end
191
+
192
+ sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})"
193
+ db.execute(sql)
194
+ true
195
+ end
196
+
197
+ def scope(name, filter_sql, params = [])
198
+ define_singleton_method(name) do |limit: 20, skip: 0|
199
+ where(filter_sql, params)
200
+ end
201
+ end
202
+
46
203
  def from_hash(hash)
47
204
  instance = new
205
+ mapping_reverse = field_mapping.invert
48
206
  hash.each do |key, value|
49
- setter = "#{key}="
50
- instance.send(setter, value) if instance.respond_to?(setter)
207
+ # Apply field mapping (db_col => ruby_attr)
208
+ attr_name = mapping_reverse[key.to_s] || key
209
+ setter = "#{attr_name}="
210
+ instance.__send__(setter, value) if instance.respond_to?(setter)
51
211
  end
52
212
  instance.instance_variable_set(:@persisted, true)
53
213
  instance
54
214
  end
215
+
216
+ private
217
+
218
+ def find_by_id(id)
219
+ pk = primary_key_field || :id
220
+ sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
221
+ if soft_delete
222
+ sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
223
+ end
224
+ result = db.fetch_one(sql, [id])
225
+ return nil unless result
226
+ from_hash(result)
227
+ end
228
+
229
+ def find_by_filter(filter)
230
+ where_parts = filter.keys.map { |k| "#{k} = ?" }
231
+ sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
232
+ if soft_delete
233
+ sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
234
+ end
235
+ results = db.fetch(sql, filter.values)
236
+ results.map { |row| from_hash(row) }
237
+ end
55
238
  end
56
239
 
57
240
  def initialize(attributes = {})
58
241
  @persisted = false
59
242
  @errors = []
243
+ @relationship_cache = {}
60
244
  attributes.each do |key, value|
61
245
  setter = "#{key}="
62
- send(setter, value) if respond_to?(setter)
246
+ __send__(setter, value) if respond_to?(setter)
63
247
  end
64
248
  # Set defaults
65
249
  self.class.field_definitions.each do |name, opts|
66
- if send(name).nil? && opts[:default]
67
- send("#{name}=", opts[:default])
250
+ if __send__(name).nil? && opts[:default]
251
+ __send__("#{name}=", opts[:default])
68
252
  end
69
253
  end
70
254
  end
@@ -74,18 +258,21 @@ module Tina4
74
258
  validate_fields
75
259
  return false unless @errors.empty?
76
260
 
77
- data = to_hash(exclude_nil: true)
261
+ data = to_db_hash(exclude_nil: true)
78
262
  pk = self.class.primary_key_field || :id
79
- pk_value = send(pk)
263
+ pk_value = __send__(pk)
80
264
 
81
265
  if @persisted && pk_value
82
266
  filter = { pk => pk_value }
83
267
  data.delete(pk)
268
+ # Remove mapped primary key too
269
+ mapped_pk = self.class.field_mapping[pk.to_s]
270
+ data.delete(mapped_pk.to_sym) if mapped_pk
84
271
  self.class.db.update(self.class.table_name, data, filter)
85
272
  else
86
273
  result = self.class.db.insert(self.class.table_name, data)
87
274
  if result[:last_id] && respond_to?("#{pk}=")
88
- send("#{pk}=", result[:last_id])
275
+ __send__("#{pk}=", result[:last_id])
89
276
  end
90
277
  @persisted = true
91
278
  end
@@ -97,25 +284,75 @@ module Tina4
97
284
 
98
285
  def delete
99
286
  pk = self.class.primary_key_field || :id
100
- pk_value = send(pk)
287
+ pk_value = __send__(pk)
101
288
  return false unless pk_value
102
289
 
290
+ if self.class.soft_delete
291
+ # Soft delete: set the flag
292
+ self.class.db.update(
293
+ self.class.table_name,
294
+ { self.class.soft_delete_field => 1 },
295
+ { pk => pk_value }
296
+ )
297
+ else
298
+ self.class.db.delete(self.class.table_name, { pk => pk_value })
299
+ end
300
+ @persisted = false
301
+ true
302
+ end
303
+
304
+ def force_delete
305
+ pk = self.class.primary_key_field || :id
306
+ pk_value = __send__(pk)
307
+ raise "Cannot delete: no primary key value" unless pk_value
308
+
103
309
  self.class.db.delete(self.class.table_name, { pk => pk_value })
104
310
  @persisted = false
105
311
  true
106
312
  end
107
313
 
314
+ def restore
315
+ raise "Model does not support soft delete" unless self.class.soft_delete
316
+
317
+ pk = self.class.primary_key_field || :id
318
+ pk_value = __send__(pk)
319
+ raise "Cannot restore: no primary key value" unless pk_value
320
+
321
+ self.class.db.update(
322
+ self.class.table_name,
323
+ { self.class.soft_delete_field => 0 },
324
+ { pk => pk_value }
325
+ )
326
+ __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
327
+ true
328
+ end
329
+
330
+ def validate
331
+ errors = []
332
+ self.class.field_definitions.each do |name, opts|
333
+ value = __send__(name)
334
+ if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
335
+ errors << "#{name} cannot be null"
336
+ end
337
+ end
338
+ errors
339
+ end
340
+
108
341
  def load(id = nil)
109
342
  pk = self.class.primary_key_field || :id
110
- id ||= send(pk)
343
+ id ||= __send__(pk)
111
344
  return false unless id
112
345
 
113
- result = self.class.db.fetch_one("SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id])
346
+ result = self.class.db.fetch_one(
347
+ "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
348
+ )
114
349
  return false unless result
115
350
 
351
+ mapping_reverse = self.class.field_mapping.invert
116
352
  result.each do |key, value|
117
- setter = "#{key}="
118
- send(setter, value) if respond_to?(setter)
353
+ attr_name = mapping_reverse[key.to_s] || key
354
+ setter = "#{attr_name}="
355
+ __send__(setter, value) if respond_to?(setter)
119
356
  end
120
357
  @persisted = true
121
358
  true
@@ -129,40 +366,109 @@ module Tina4
129
366
  @errors
130
367
  end
131
368
 
132
- def to_hash(exclude_nil: false)
369
+ # Convert to hash using Ruby attribute names
370
+ def to_h
133
371
  hash = {}
134
372
  self.class.field_definitions.each_key do |name|
135
- value = send(name)
136
- next if exclude_nil && value.nil?
137
- hash[name] = value
373
+ hash[name] = __send__(name)
138
374
  end
139
375
  hash
140
376
  end
141
377
 
378
+ alias to_hash to_h
379
+ alias to_dict to_h
380
+ alias to_object to_h
381
+
382
+ def to_array
383
+ to_h.values
384
+ end
385
+
386
+ alias to_list to_array
387
+
142
388
  def to_json(*_args)
143
- JSON.generate(to_hash)
389
+ JSON.generate(to_h)
144
390
  end
145
391
 
146
392
  def to_s
147
- "#<#{self.class.name} #{to_hash}>"
393
+ "#<#{self.class.name} #{to_h}>"
148
394
  end
149
395
 
150
396
  def select(*fields)
151
397
  fields_str = fields.map(&:to_s).join(", ")
152
398
  pk = self.class.primary_key_field || :id
153
- pk_value = send(pk)
399
+ pk_value = __send__(pk)
154
400
  self.class.db.fetch_one("SELECT #{fields_str} FROM #{self.class.table_name} WHERE #{pk} = ?", [pk_value])
155
401
  end
156
402
 
157
403
  private
158
404
 
405
+ # Convert to hash using DB column names (with field_mapping applied)
406
+ def to_db_hash(exclude_nil: false)
407
+ hash = {}
408
+ mapping = self.class.field_mapping
409
+ self.class.field_definitions.each_key do |name|
410
+ value = __send__(name)
411
+ next if exclude_nil && value.nil?
412
+ db_col = mapping[name.to_s] || name
413
+ hash[db_col.to_sym] = value
414
+ end
415
+ hash
416
+ end
417
+
159
418
  def validate_fields
160
419
  self.class.field_definitions.each do |name, opts|
161
- value = send(name)
420
+ value = __send__(name)
162
421
  if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
163
422
  @errors << "#{name} cannot be null"
164
423
  end
165
424
  end
166
425
  end
426
+
427
+ def load_has_one(name)
428
+ return @relationship_cache[name] if @relationship_cache.key?(name)
429
+ rel = self.class.relationship_definitions[name]
430
+ return nil unless rel
431
+
432
+ klass = Object.const_get(rel[:class_name])
433
+ pk = self.class.primary_key_field || :id
434
+ fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
435
+ pk_value = __send__(pk)
436
+ return nil unless pk_value
437
+
438
+ result = klass.db.fetch_one(
439
+ "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
440
+ )
441
+ @relationship_cache[name] = result ? klass.from_hash(result) : nil
442
+ end
443
+
444
+ def load_has_many(name)
445
+ return @relationship_cache[name] if @relationship_cache.key?(name)
446
+ rel = self.class.relationship_definitions[name]
447
+ return [] unless rel
448
+
449
+ klass = Object.const_get(rel[:class_name])
450
+ pk = self.class.primary_key_field || :id
451
+ fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
452
+ pk_value = __send__(pk)
453
+ return [] unless pk_value
454
+
455
+ results = klass.db.fetch(
456
+ "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
457
+ )
458
+ @relationship_cache[name] = results.map { |row| klass.from_hash(row) }
459
+ end
460
+
461
+ def load_belongs_to(name)
462
+ return @relationship_cache[name] if @relationship_cache.key?(name)
463
+ rel = self.class.relationship_definitions[name]
464
+ return nil unless rel
465
+
466
+ klass = Object.const_get(rel[:class_name])
467
+ fk = rel[:foreign_key] || "#{name}_id"
468
+ fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
469
+ return nil unless fk_value
470
+
471
+ @relationship_cache[name] = klass.find(fk_value)
472
+ end
167
473
  end
168
474
  end
@@ -1,4 +1,3 @@
1
- /* Tina4 CSS Framework v2.0.0 | MIT License | https://github.com/tina4stack/tina4-css */
2
1
  *,
3
2
  *::before,
4
3
  *::after {
@@ -1515,6 +1514,64 @@ textarea.form-control {
1515
1514
  background-color: #212529;
1516
1515
  }
1517
1516
 
1517
+ .pagination {
1518
+ display: flex;
1519
+ flex-wrap: wrap;
1520
+ padding-left: 0;
1521
+ list-style: none;
1522
+ gap: 0.25rem;
1523
+ }
1524
+
1525
+ .page-item.disabled .page-link {
1526
+ color: #6c757d;
1527
+ pointer-events: none;
1528
+ background-color: #fff;
1529
+ border-color: rgba(0, 0, 0, 0.1);
1530
+ }
1531
+
1532
+ .page-item.active .page-link {
1533
+ color: #fff;
1534
+ background-color: #4a90d9;
1535
+ border-color: #4a90d9;
1536
+ }
1537
+
1538
+ .page-link {
1539
+ display: block;
1540
+ padding: 0.375rem 0.75rem;
1541
+ color: #4a90d9;
1542
+ background-color: #fff;
1543
+ border: 1px solid rgba(0, 0, 0, 0.15);
1544
+ border-radius: 0.25rem;
1545
+ text-decoration: none;
1546
+ line-height: 1.25;
1547
+ transition: color 0.15s, background-color 0.15s, border-color 0.15s;
1548
+ }
1549
+
1550
+ .page-link:hover {
1551
+ color: #2a76c6;
1552
+ background-color: rgba(74, 144, 217, 0.08);
1553
+ border-color: rgba(0, 0, 0, 0.2);
1554
+ }
1555
+
1556
+ .page-link:focus {
1557
+ outline: 0;
1558
+ box-shadow: 0 0 0 0.2rem rgba(74, 144, 217, 0.25);
1559
+ }
1560
+
1561
+ .crud-img-preview {
1562
+ max-height: 200px;
1563
+ max-width: 100%;
1564
+ border-radius: 0.25rem;
1565
+ object-fit: cover;
1566
+ }
1567
+
1568
+ .crud-img-preview-sm {
1569
+ max-height: 180px;
1570
+ max-width: 100%;
1571
+ border-radius: 0.25rem;
1572
+ object-fit: cover;
1573
+ }
1574
+
1518
1575
  .d-none {
1519
1576
  display: none !important;
1520
1577
  }
@@ -1793,6 +1850,66 @@ textarea.form-control {
1793
1850
  margin-right: 3rem !important;
1794
1851
  }
1795
1852
 
1853
+ .my-0 {
1854
+ margin-top: 0 !important;
1855
+ margin-bottom: 0 !important;
1856
+ }
1857
+
1858
+ .mx-0 {
1859
+ margin-left: 0 !important;
1860
+ margin-right: 0 !important;
1861
+ }
1862
+
1863
+ .my-1 {
1864
+ margin-top: 0.25rem !important;
1865
+ margin-bottom: 0.25rem !important;
1866
+ }
1867
+
1868
+ .mx-1 {
1869
+ margin-left: 0.25rem !important;
1870
+ margin-right: 0.25rem !important;
1871
+ }
1872
+
1873
+ .my-2 {
1874
+ margin-top: 0.5rem !important;
1875
+ margin-bottom: 0.5rem !important;
1876
+ }
1877
+
1878
+ .mx-2 {
1879
+ margin-left: 0.5rem !important;
1880
+ margin-right: 0.5rem !important;
1881
+ }
1882
+
1883
+ .my-3 {
1884
+ margin-top: 1rem !important;
1885
+ margin-bottom: 1rem !important;
1886
+ }
1887
+
1888
+ .mx-3 {
1889
+ margin-left: 1rem !important;
1890
+ margin-right: 1rem !important;
1891
+ }
1892
+
1893
+ .my-4 {
1894
+ margin-top: 1.5rem !important;
1895
+ margin-bottom: 1.5rem !important;
1896
+ }
1897
+
1898
+ .mx-4 {
1899
+ margin-left: 1.5rem !important;
1900
+ margin-right: 1.5rem !important;
1901
+ }
1902
+
1903
+ .my-5 {
1904
+ margin-top: 3rem !important;
1905
+ margin-bottom: 3rem !important;
1906
+ }
1907
+
1908
+ .mx-5 {
1909
+ margin-left: 3rem !important;
1910
+ margin-right: 3rem !important;
1911
+ }
1912
+
1796
1913
  .p-0 {
1797
1914
  padding: 0 !important;
1798
1915
  }
@@ -1913,6 +2030,66 @@ textarea.form-control {
1913
2030
  padding-right: 3rem !important;
1914
2031
  }
1915
2032
 
2033
+ .py-0 {
2034
+ padding-top: 0 !important;
2035
+ padding-bottom: 0 !important;
2036
+ }
2037
+
2038
+ .px-0 {
2039
+ padding-left: 0 !important;
2040
+ padding-right: 0 !important;
2041
+ }
2042
+
2043
+ .py-1 {
2044
+ padding-top: 0.25rem !important;
2045
+ padding-bottom: 0.25rem !important;
2046
+ }
2047
+
2048
+ .px-1 {
2049
+ padding-left: 0.25rem !important;
2050
+ padding-right: 0.25rem !important;
2051
+ }
2052
+
2053
+ .py-2 {
2054
+ padding-top: 0.5rem !important;
2055
+ padding-bottom: 0.5rem !important;
2056
+ }
2057
+
2058
+ .px-2 {
2059
+ padding-left: 0.5rem !important;
2060
+ padding-right: 0.5rem !important;
2061
+ }
2062
+
2063
+ .py-3 {
2064
+ padding-top: 1rem !important;
2065
+ padding-bottom: 1rem !important;
2066
+ }
2067
+
2068
+ .px-3 {
2069
+ padding-left: 1rem !important;
2070
+ padding-right: 1rem !important;
2071
+ }
2072
+
2073
+ .py-4 {
2074
+ padding-top: 1.5rem !important;
2075
+ padding-bottom: 1.5rem !important;
2076
+ }
2077
+
2078
+ .px-4 {
2079
+ padding-left: 1.5rem !important;
2080
+ padding-right: 1.5rem !important;
2081
+ }
2082
+
2083
+ .py-5 {
2084
+ padding-top: 3rem !important;
2085
+ padding-bottom: 3rem !important;
2086
+ }
2087
+
2088
+ .px-5 {
2089
+ padding-left: 3rem !important;
2090
+ padding-right: 3rem !important;
2091
+ }
2092
+
1916
2093
  .mx-auto {
1917
2094
  margin-left: auto !important;
1918
2095
  margin-right: auto !important;