dbviewer 0.4.8 → 0.5.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03dfb13567e8cf7a2eaf2977e421fec7a2117ff12a6462515399a4b7cfb056d1
4
- data.tar.gz: 77ae23ada80eabbc1a5744376a96c0d5df4f20f0cb3723be4c0f9c9967994716
3
+ metadata.gz: d16f8924e91f06f61088b4948397aa23530cd6e4a89e00ab483cc047ea5fc492
4
+ data.tar.gz: 98091d292dfe7c42f0644dc1734fb298f532cfc6f99e0176ff1ae517c8db1cc8
5
5
  SHA512:
6
- metadata.gz: 35d69706133dc764c081a6bc172dc0d99f39c2eaa7d62db9c17664c5a1fe05b78255932bd14a3f9a9542e99cb74020cb2926d69c9d21d9a339776fc4023098e9
7
- data.tar.gz: b3a4e2029d81da79e922fb5ee0e889fc9e1f66e66129136333f6820d66dbc1dd7648da63a4ab99d3ed822e8cc64c77e9815a1c1bd34b4d0a11b208118d2eb72c
6
+ metadata.gz: a423fec219eaf8f40adde10a316cd4e45f33eb27d514ead5a0da735db3a475ac1ac430851cc347095106ca011dda72eae4ab68a01cba8ea8782ab132c99de8b6
7
+ data.tar.gz: 57594e053bf4bd1389da099fa91236af80e579bfc6f0ebd72da5081187c16a2343717c803632781bf39eb8d5281151cc8614def62c035b1b35dfd2f8946da631
data/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  DBViewer is a powerful Rails engine that provides a comprehensive interface to view and explore database tables, records, and schema.
6
6
  It's designed for development, debugging, and database analysis, offering a clean and intuitive way to interact with your application's database.
7
7
 
8
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/c946a286-e80a-4cca-afa0-654052e4ef2c" />
8
+ <img width="1470" alt="image" src="https://github.com/user-attachments/assets/0d2719ad-f5b4-4818-891d-5bff7be6c5c3" />
9
+
9
10
 
10
11
  ## ✨ Features
11
12
 
@@ -45,27 +46,10 @@ It's designed for development, debugging, and database analysis, offering a clea
45
46
 
46
47
  <details>
47
48
  <summary>Click to see more screenshots</summary>
48
-
49
- #### Dashboard Overview
50
-
51
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/4e803d51-9a5b-4c80-bb4c-a761dba15a40" />
52
-
53
- #### Table Details
54
-
55
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/fe425ab4-5b22-4839-87bc-050b80ad4cf0" />
56
-
57
- #### Query Editor
58
-
59
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/392c73c7-0724-4a39-8ffa-8ff5115c5d5f" />
60
-
61
- #### Query Logs
62
-
63
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/7fcf3355-be3c-4d6a-9ab0-811333be5bbc" />
64
49
 
65
- #### ERD
50
+ <img width="1470" alt="image" src="https://github.com/user-attachments/assets/7d708c14-5f78-42c4-b769-2167546b3aad" />
51
+ <img width="1470" alt="image" src="https://github.com/user-attachments/assets/f6d9a39a-a571-4328-908a-d96b3148f707" />
66
52
 
67
- <img width="1470" alt="image" src="https://github.com/user-attachments/assets/0a2f838f-4ca6-4592-b939-7c7f8ac40f48" />
68
-
69
53
  </details>
70
54
 
71
55
  ## 📥 Installation
@@ -5,7 +5,7 @@ module Dbviewer
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- helper_method :current_table?, :get_database_name if respond_to?(:helper_method)
8
+ helper_method :current_table?, :get_database_name, :get_adapter_name if respond_to?(:helper_method)
9
9
  end
10
10
 
11
11
  # Initialize the database manager
@@ -44,13 +44,36 @@ module Dbviewer
44
44
  "Database"
45
45
  end
46
46
 
47
+ # Get the name of the current database adapter
48
+ def get_adapter_name
49
+ adapter = database_manager.connection.adapter_name
50
+
51
+ # Format the adapter name for better display
52
+ case adapter.downcase
53
+ when /mysql/
54
+ "MySQL"
55
+ when /postgres/
56
+ "PostgreSQL"
57
+ when /sqlite/
58
+ "SQLite"
59
+ when /oracle/
60
+ "Oracle"
61
+ when /sqlserver/, /mssql/
62
+ "SQL Server"
63
+ else
64
+ adapter.titleize # Fallback to titleized version
65
+ end
66
+ rescue => e
67
+ Rails.logger.error("Error retrieving adapter name: #{e.message}")
68
+ "Unknown"
69
+ end
70
+
47
71
  # Fetch all tables with their stats
48
72
  # By default, don't include record counts for better performance on sidebar
49
- def fetch_tables_with_stats(include_record_counts = false)
73
+ def fetch_tables(include_record_counts = false)
50
74
  database_manager.tables.map do |table_name|
51
75
  table_stats = {
52
76
  name: table_name
53
- # columns_count: database_manager.column_count(table_name)
54
77
  }
55
78
 
56
79
  # Only fetch record counts if explicitly requested
@@ -63,7 +86,7 @@ module Dbviewer
63
86
  # Gather database analytics information
64
87
  def fetch_database_analytics
65
88
  # For analytics, we do need record counts
66
- tables = fetch_tables_with_stats(include_record_counts: true)
89
+ tables = fetch_tables(include_record_counts: true)
67
90
 
68
91
  # Calculate overall statistics
69
92
  analytics = {
@@ -72,82 +95,12 @@ module Dbviewer
72
95
  largest_tables: tables.sort_by { |t| -t[:record_count] }.first(10),
73
96
  empty_tables: tables.select { |t| t[:record_count] == 0 }
74
97
  }
75
-
76
- # Calculate total foreign key relationships
77
- begin
78
- total_relationships = 0
79
- tables.each do |table|
80
- metadata = fetch_table_metadata(table[:name])
81
- total_relationships += metadata[:foreign_keys].size if metadata && metadata[:foreign_keys]
82
- end
83
- analytics[:total_relationships] = total_relationships
84
- rescue => e
85
- Rails.logger.error("Error calculating relationship count: #{e.message}")
86
- analytics[:total_relationships] = 0
87
- end
88
-
89
98
  # Calculate schema size if possible
90
- begin
91
- analytics[:schema_size] = calculate_schema_size
92
- rescue => e
93
- Rails.logger.error("Error calculating schema size: #{e.message}")
94
- analytics[:schema_size] = nil
95
- end
96
-
97
- # Calculate average rows per table
98
- if tables.any?
99
- analytics[:avg_records_per_table] = (analytics[:total_records].to_f / tables.size).round(1)
100
- analytics[:avg_columns_per_table] = (analytics[:total_columns].to_f / tables.size).round(1)
101
- else
102
- analytics[:avg_records_per_table] = 0
103
- analytics[:avg_columns_per_table] = 0
104
- end
99
+ analytics[:schema_size] = calculate_schema_size
105
100
 
106
101
  analytics
107
102
  end
108
103
 
109
- # Calculate approximate schema size
110
- def calculate_schema_size
111
- adapter = database_manager.connection.adapter_name.downcase
112
-
113
- case adapter
114
- when /mysql/
115
- query = <<-SQL
116
- SELECT
117
- SUM(data_length + index_length) AS size
118
- FROM
119
- information_schema.TABLES
120
- WHERE
121
- table_schema = DATABASE()
122
- SQL
123
- result = database_manager.execute_query(query).first
124
- result ? result["size"].to_i : nil
125
- when /postgres/
126
- query = <<-SQL
127
- SELECT pg_database_size(current_database()) AS size
128
- SQL
129
- result = database_manager.execute_query(query).first
130
- result ? result["size"].to_i : nil
131
- when /sqlite/
132
- # For SQLite, we need to use the special PRAGMA method without LIMIT
133
- # Get page count
134
- page_count_result = database_manager.execute_sqlite_pragma("page_count")
135
- page_count = page_count_result.first.values.first.to_i
136
-
137
- # Get page size
138
- page_size_result = database_manager.execute_sqlite_pragma("page_size")
139
- page_size = page_size_result.first.values.first.to_i
140
-
141
- # Calculate total size
142
- page_count * page_size
143
- else
144
- nil # Unsupported database type for size calculation
145
- end
146
- rescue => e
147
- Rails.logger.error("Error calculating database size: #{e.message}")
148
- nil
149
- end
150
-
151
104
  # Get column information for a specific table
152
105
  def fetch_table_columns(table_name)
153
106
  database_manager.table_columns(table_name)
@@ -0,0 +1,19 @@
1
+ module Dbviewer
2
+ module Api
3
+ class BaseController < ApplicationController
4
+ # Skip setting the tables instance variable for API endpoints since we don't need it
5
+ skip_before_action :set_tables
6
+
7
+ # Common API response handling for errors
8
+ def render_error(error_message, status = :internal_server_error)
9
+ Rails.logger.error(error_message)
10
+ render json: { error: error_message }, status: status
11
+ end
12
+
13
+ # Common API response handling for success
14
+ def render_success(data)
15
+ render json: data
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ module Dbviewer
2
+ module Api
3
+ class DatabaseController < BaseController
4
+ def size
5
+ size = database_manager.fetch_schema_size
6
+ render_success(schema_size: size)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ module Dbviewer
2
+ module Api
3
+ class QueriesController < BaseController
4
+ def recent
5
+ render_success({
6
+ enabled: query_logging_enabled?,
7
+ queries: fetch_recent_queries
8
+ })
9
+ end
10
+
11
+ private
12
+
13
+ def fetch_recent_queries
14
+ return [] unless query_logging_enabled?
15
+
16
+ Dbviewer::Logger.instance.recent_queries(limit: queries_limit)
17
+ end
18
+
19
+ def query_logging_enabled?
20
+ Dbviewer.configuration.enable_query_logging
21
+ end
22
+
23
+ def queries_limit
24
+ 10
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,64 @@
1
+ module Dbviewer
2
+ module Api
3
+ class TablesController < BaseController
4
+ def index
5
+ tables_count = fetch_tables_count
6
+ render_success(total_tables: tables_count)
7
+ end
8
+
9
+ def records
10
+ tables_stats = fetch_tables_stats
11
+ render_success(tables_stats)
12
+ end
13
+
14
+ def relationships_count
15
+ total_relationships = calculate_total_relationships
16
+ render_success(total_relationships: total_relationships)
17
+ end
18
+
19
+ private
20
+
21
+ def fetch_tables_count
22
+ fetch_tables(include_record_counts: false).size
23
+ end
24
+
25
+ def fetch_tables_stats
26
+ tables = fetch_tables(include_record_counts: true)
27
+
28
+ {
29
+ total_records: calculate_total_records(tables),
30
+ largest_tables: find_largest_tables(tables),
31
+ empty_tables: find_empty_tables(tables),
32
+ avg_records_per_table: calculate_average_records(tables)
33
+ }
34
+ end
35
+
36
+ def calculate_total_relationships
37
+ tables = fetch_tables(include_record_counts: false)
38
+
39
+ tables.sum do |table|
40
+ metadata = fetch_table_metadata(table[:name])
41
+ metadata&.dig(:foreign_keys)&.size || 0
42
+ end
43
+ end
44
+
45
+ def calculate_total_records(tables)
46
+ tables.sum { |table| table[:record_count] }
47
+ end
48
+
49
+ def find_largest_tables(tables, limit = 10)
50
+ tables.sort_by { |table| -table[:record_count] }.first(limit)
51
+ end
52
+
53
+ def find_empty_tables(tables)
54
+ tables.select { |table| table[:record_count] == 0 }
55
+ end
56
+
57
+ def calculate_average_records(tables)
58
+ return 0 if tables.empty?
59
+
60
+ (calculate_total_records(tables).to_f / tables.size).round(1)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -19,7 +19,7 @@ module Dbviewer
19
19
  end
20
20
 
21
21
  def set_tables
22
- @tables = fetch_tables_with_stats
22
+ @tables = fetch_tables
23
23
  end
24
24
  end
25
25
  end
@@ -1,104 +1,12 @@
1
1
  module Dbviewer
2
2
  class HomeController < ApplicationController
3
3
  def index
4
- # Load page immediately without heavy data
5
- # Data will be loaded asynchronously via AJAX
6
- end
7
-
8
- def analytics
9
- # This method is deprecated but kept for backward compatibility
10
- analytics_data = fetch_database_analytics
11
- # Remove record data which will be served by the records endpoint
12
- analytics_data.delete(:total_records)
13
- analytics_data.delete(:largest_tables)
14
- analytics_data.delete(:empty_tables)
15
- analytics_data.delete(:avg_records_per_table)
16
-
17
- respond_to do |format|
18
- format.json { render json: analytics_data }
19
- end
20
- end
21
-
22
- def tables_count
23
- tables = fetch_tables_with_stats(include_record_counts: false)
24
-
25
- respond_to do |format|
26
- format.json { render json: { total_tables: tables.size } }
27
- end
28
- end
29
-
30
- def relationships_count
31
- begin
32
- tables = fetch_tables_with_stats(include_record_counts: false)
33
- total_relationships = 0
34
-
35
- tables.each do |table|
36
- metadata = fetch_table_metadata(table[:name])
37
- total_relationships += metadata[:foreign_keys].size if metadata && metadata[:foreign_keys]
38
- end
39
-
40
- respond_to do |format|
41
- format.json { render json: { total_relationships: total_relationships } }
42
- end
43
- rescue => e
44
- Rails.logger.error("Error calculating relationship count: #{e.message}")
45
- respond_to do |format|
46
- format.json { render json: { total_relationships: 0, error: e.message }, status: :internal_server_error }
47
- end
48
- end
49
- end
50
-
51
- def database_size
52
- begin
53
- size = calculate_schema_size
54
-
55
- respond_to do |format|
56
- format.json { render json: { schema_size: size } }
57
- end
58
- rescue => e
59
- Rails.logger.error("Error calculating schema size: #{e.message}")
60
- respond_to do |format|
61
- format.json { render json: { schema_size: nil, error: e.message }, status: :internal_server_error }
62
- end
63
- end
64
- end
65
-
66
- def records
67
- tables = fetch_tables_with_stats(include_record_counts: true)
68
-
69
- records_data = {
70
- total_records: tables.sum { |t| t[:record_count] },
71
- largest_tables: tables.sort_by { |t| -t[:record_count] }.first(10),
72
- empty_tables: tables.select { |t| t[:record_count] == 0 },
73
- avg_records_per_table: tables.any? ? (tables.sum { |t| t[:record_count] }.to_f / tables.size).round(1) : 0
74
- }
75
-
76
- respond_to do |format|
77
- format.json { render json: records_data }
78
- end
79
- end
80
-
81
- def recent_queries
82
- @recent_queries = if Dbviewer.configuration.enable_query_logging
83
- Dbviewer::Logger.instance.recent_queries(limit: 10)
84
- else
85
- []
86
- end
87
-
88
- respond_to do |format|
89
- format.json do
90
- render json: {
91
- enabled: Dbviewer.configuration.enable_query_logging,
92
- queries: @recent_queries
93
- }
94
- end
95
- end
96
4
  end
97
5
 
98
6
  private
99
7
 
100
8
  def set_tables
101
- @tables = fetch_tables_with_stats(include_record_counts: true)
9
+ @tables = fetch_tables(include_record_counts: true)
102
10
  end
103
11
  end
104
12
  end
@@ -7,7 +7,7 @@ module Dbviewer
7
7
  before_action :set_global_filters, only: [ :show, :export_csv ]
8
8
 
9
9
  def index
10
- @tables = fetch_tables_with_stats(include_record_counts: true)
10
+ @tables = fetch_tables(include_record_counts: true)
11
11
  end
12
12
 
13
13
  def show
@@ -60,7 +60,7 @@ module Dbviewer
60
60
  def query
61
61
  @read_only_mode = true # Flag to indicate we're in read-only mode
62
62
  @columns = fetch_table_columns(@table_name)
63
- @tables = fetch_tables_with_stats # Fetch tables for sidebar
63
+ @tables = fetch_tables # Fetch tables for sidebar
64
64
 
65
65
  prepare_query
66
66
  execute_query
@@ -3,12 +3,12 @@
3
3
  <div class="col">
4
4
  <h1 class="h3 mb-2">Database Overview</h1>
5
5
  <p class="database-connection-info">
6
- Connected to database: <span class="badge rounded-pill database-name-badge"><%= get_database_name %></span>
6
+ Connected to <%= get_adapter_name %> database: <span class="badge rounded-pill database-name-badge"><%= get_database_name %></span>
7
7
  </p>
8
8
  </div>
9
9
  </div>
10
10
 
11
- <div class="row g-3 mb-4" id="analytics-cards">
11
+ <div class="row g-3 mb-4 dashboard-analytics-cards">
12
12
  <div class="col-md-4">
13
13
  <div class="card h-100 border-0 shadow-sm <%= stat_card_bg_class %>">
14
14
  <div class="card-body d-flex align-items-center">
@@ -299,7 +299,7 @@ document.addEventListener('DOMContentLoaded', function() {
299
299
  }
300
300
 
301
301
  // Load tables count data
302
- fetch('<%= api_tables_path %>', {
302
+ fetch('<%= dbviewer.api_tables_path %>', {
303
303
  headers: {
304
304
  'Accept': 'application/json',
305
305
  'X-Requested-With': 'XMLHttpRequest'
@@ -324,7 +324,7 @@ document.addEventListener('DOMContentLoaded', function() {
324
324
  });
325
325
 
326
326
  // Load database size data
327
- fetch('<%= api_database_size_path %>', {
327
+ fetch('<%= dbviewer.size_api_database_path %>', {
328
328
  headers: {
329
329
  'Accept': 'application/json',
330
330
  'X-Requested-With': 'XMLHttpRequest'
@@ -349,7 +349,7 @@ document.addEventListener('DOMContentLoaded', function() {
349
349
  });
350
350
 
351
351
  // Load records data separately
352
- fetch('<%= api_records_path %>', {
352
+ fetch('<%= dbviewer.records_api_tables_path %>', {
353
353
  headers: {
354
354
  'Accept': 'application/json',
355
355
  'X-Requested-With': 'XMLHttpRequest'
@@ -377,7 +377,7 @@ document.addEventListener('DOMContentLoaded', function() {
377
377
  });
378
378
 
379
379
  // Load recent queries data
380
- fetch('<%= api_recent_queries_path %>', {
380
+ fetch('<%= dbviewer.recent_api_queries_path %>', {
381
381
  headers: {
382
382
  'Accept': 'application/json',
383
383
  'X-Requested-With': 'XMLHttpRequest'
@@ -314,7 +314,7 @@
314
314
  <% content_for :sidebar_active do %>active<% end %>
315
315
 
316
316
  <div class="d-flex justify-content-between align-items-center mb-4">
317
- <div>
317
+ <div class="d-flex justify-content-between align-items-center">
318
318
  <h1>Table: <%= @table_name %></h1>
319
319
  </div>
320
320
  <div class="d-flex gap-2">
@@ -393,7 +393,7 @@
393
393
  </div>
394
394
 
395
395
  <!-- Records Section -->
396
- <div class="dbviewer-card card mb-4">
396
+ <div class="dbviewer-card card mb-4" id="table-section">
397
397
  <div class="card-header d-flex justify-content-between align-items-center">
398
398
  <h5 class="mb-0">
399
399
  <select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?<%= per_page_url_params(@table_name) %>'">
@@ -414,6 +414,9 @@
414
414
  <% if active_filters > 0 %>
415
415
  <span class="badge bg-info ms-2" title="Active filters"><i class="bi bi-funnel-fill me-1"></i><%= active_filters %></span>
416
416
  <% end %>
417
+ <button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="fullscreen-toggle" title="Toggle fullscreen">
418
+ <i class="bi bi-fullscreen" id="fullscreen-icon"></i>
419
+ </button>
417
420
  </div>
418
421
  </div>
419
422
  <div class="card-body p-0">
@@ -1311,6 +1314,71 @@
1311
1314
  [data-bs-theme="dark"] .record-detail-table .code-block {
1312
1315
  background-color: var(--bs-dark);
1313
1316
  }
1317
+
1318
+ /* Fullscreen table styles */
1319
+ .table-fullscreen {
1320
+ position: fixed !important;
1321
+ top: 0 !important;
1322
+ left: 0 !important;
1323
+ width: 100vw !important;
1324
+ height: 100vh !important;
1325
+ z-index: 9999 !important;
1326
+ background: var(--bs-body-bg) !important;
1327
+ margin: 0 !important;
1328
+ border-radius: 0 !important;
1329
+ overflow: hidden !important;
1330
+ display: flex !important;
1331
+ flex-direction: column !important;
1332
+ }
1333
+
1334
+ .table-fullscreen .card-body {
1335
+ flex: 1 !important;
1336
+ overflow: hidden !important;
1337
+ display: flex !important;
1338
+ flex-direction: column !important;
1339
+ }
1340
+
1341
+ .table-fullscreen .table-responsive {
1342
+ flex: 1 !important;
1343
+ overflow: auto !important;
1344
+ }
1345
+
1346
+ .table-fullscreen .card-header {
1347
+ flex-shrink: 0 !important;
1348
+ position: sticky !important;
1349
+ top: 0 !important;
1350
+ z-index: 10000 !important;
1351
+ background: var(--bs-body-bg) !important;
1352
+ border-bottom: 1px solid var(--bs-border-color) !important;
1353
+ }
1354
+
1355
+ /* Hide pagination in fullscreen mode */
1356
+ .table-fullscreen .pagination-container {
1357
+ display: none !important;
1358
+ }
1359
+
1360
+ /* Adjust table header in fullscreen */
1361
+ .table-fullscreen .dbviewer-table-header {
1362
+ position: sticky !important;
1363
+ top: 0 !important;
1364
+ z-index: 100 !important;
1365
+ }
1366
+
1367
+ /* Ensure body doesn't scroll when table is fullscreen */
1368
+ body.table-fullscreen-active {
1369
+ overflow: hidden !important;
1370
+ }
1371
+
1372
+ /* Fullscreen button hover effect */
1373
+ #fullscreen-toggle:hover {
1374
+ background-color: var(--bs-secondary-bg) !important;
1375
+ border-color: var(--bs-secondary-border-subtle) !important;
1376
+ }
1377
+
1378
+ /* Smooth transitions */
1379
+ #table-section {
1380
+ transition: all 0.3s ease-in-out;
1381
+ }
1314
1382
  </style>
1315
1383
 
1316
1384
  <% if @timestamp_data.present? %>
@@ -1516,5 +1584,70 @@
1516
1584
 
1517
1585
  return section;
1518
1586
  }
1587
+
1588
+ // Table fullscreen functionality
1589
+ document.addEventListener('DOMContentLoaded', function() {
1590
+ const fullscreenToggle = document.getElementById('fullscreen-toggle');
1591
+ const fullscreenIcon = document.getElementById('fullscreen-icon');
1592
+ const tableSection = document.getElementById('table-section');
1593
+
1594
+ if (fullscreenToggle && tableSection) {
1595
+ // Key for storing fullscreen state in localStorage
1596
+ const fullscreenStateKey = 'dbviewer-table-fullscreen-<%= @table_name %>';
1597
+
1598
+ // Function to apply fullscreen state
1599
+ function applyFullscreenState(isFullscreen) {
1600
+ if (isFullscreen) {
1601
+ // Enter fullscreen
1602
+ tableSection.classList.add('table-fullscreen');
1603
+ document.body.classList.add('table-fullscreen-active');
1604
+ fullscreenIcon.classList.remove('bi-fullscreen');
1605
+ fullscreenIcon.classList.add('bi-fullscreen-exit');
1606
+ fullscreenToggle.setAttribute('title', 'Exit fullscreen');
1607
+ } else {
1608
+ // Exit fullscreen
1609
+ tableSection.classList.remove('table-fullscreen');
1610
+ document.body.classList.remove('table-fullscreen-active');
1611
+ fullscreenIcon.classList.remove('bi-fullscreen-exit');
1612
+ fullscreenIcon.classList.add('bi-fullscreen');
1613
+ fullscreenToggle.setAttribute('title', 'Toggle fullscreen');
1614
+ }
1615
+ }
1616
+
1617
+ // Restore fullscreen state from localStorage on page load
1618
+ try {
1619
+ const savedState = localStorage.getItem(fullscreenStateKey);
1620
+ if (savedState === 'true') {
1621
+ applyFullscreenState(true);
1622
+ }
1623
+ } catch (e) {
1624
+ // Handle localStorage not available (private browsing, etc.)
1625
+ console.warn('Could not restore fullscreen state:', e);
1626
+ }
1627
+
1628
+ fullscreenToggle.addEventListener('click', function() {
1629
+ const isFullscreen = tableSection.classList.contains('table-fullscreen');
1630
+ const newState = !isFullscreen;
1631
+
1632
+ // Apply the new state
1633
+ applyFullscreenState(newState);
1634
+
1635
+ // Save state to localStorage
1636
+ try {
1637
+ localStorage.setItem(fullscreenStateKey, newState.toString());
1638
+ } catch (e) {
1639
+ // Handle localStorage not available (private browsing, etc.)
1640
+ console.warn('Could not save fullscreen state:', e);
1641
+ }
1642
+ });
1643
+
1644
+ // Exit fullscreen with Escape key
1645
+ document.addEventListener('keydown', function(e) {
1646
+ if (e.key === 'Escape' && tableSection.classList.contains('table-fullscreen')) {
1647
+ fullscreenToggle.click();
1648
+ }
1649
+ });
1650
+ }
1651
+ });
1519
1652
  </script>
1520
1653
  <% end %>