markdownr 0.7.1 → 0.8.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/bin/Dockerfile.markdownr +1 -1
- data/bin/markdownr +15 -0
- data/bin/markdownr-servers.yaml +39 -0
- data/lib/markdown_server/app.rb +729 -90
- data/lib/markdown_server/csv_browser/addon_registry.rb +137 -0
- data/lib/markdown_server/csv_browser/config_loader.rb +231 -0
- data/lib/markdown_server/csv_browser/row_context.rb +146 -0
- data/lib/markdown_server/csv_browser/table_reader.rb +259 -0
- data/lib/markdown_server/helpers/admin_helpers.rb +15 -1
- data/lib/markdown_server/plugin.rb +11 -0
- data/lib/markdown_server/version.rb +1 -1
- data/views/browser.erb +4408 -0
- data/views/layout.erb +2 -15
- metadata +35 -1
data/views/browser.erb
ADDED
|
@@ -0,0 +1,4408 @@
|
|
|
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">
|
|
6
|
+
<title><%= h(@title) %></title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body {
|
|
10
|
+
background: #1a1a2e;
|
|
11
|
+
color: #e0e0e0;
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
13
|
+
font-size: 14px;
|
|
14
|
+
min-height: 100vh;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* ── Popup styles ── */
|
|
21
|
+
.br-popup {
|
|
22
|
+
position: fixed;
|
|
23
|
+
z-index: 500;
|
|
24
|
+
width: 520px;
|
|
25
|
+
max-width: calc(100vw - 32px);
|
|
26
|
+
max-height: min(70vh, calc(100vh - 32px));
|
|
27
|
+
background: #faf8f4;
|
|
28
|
+
border: 1px solid #d4b96a;
|
|
29
|
+
border-radius: 6px;
|
|
30
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
|
|
31
|
+
min-width: 240px;
|
|
32
|
+
min-height: 80px;
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
color: #3a3a3a;
|
|
37
|
+
}
|
|
38
|
+
.br-popup.pinned { max-height: min(85vh, calc(100vh - 32px)); }
|
|
39
|
+
.br-popup.wide { width: min(85vw, 900px); }
|
|
40
|
+
.br-popup-header {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 0.5rem;
|
|
44
|
+
padding: 0.5rem 0.9rem;
|
|
45
|
+
border-bottom: 1px solid #e0d8c8;
|
|
46
|
+
background: #faf8f4;
|
|
47
|
+
flex-shrink: 0;
|
|
48
|
+
cursor: grab;
|
|
49
|
+
touch-action: none;
|
|
50
|
+
user-select: none;
|
|
51
|
+
}
|
|
52
|
+
.br-popup-header.is-dragging { cursor: grabbing; }
|
|
53
|
+
.br-popup-title {
|
|
54
|
+
flex: 1;
|
|
55
|
+
font-weight: 700;
|
|
56
|
+
font-size: 0.9rem;
|
|
57
|
+
color: #3a3a3a;
|
|
58
|
+
min-width: 0;
|
|
59
|
+
word-break: break-word;
|
|
60
|
+
}
|
|
61
|
+
.br-popup-btn {
|
|
62
|
+
background: none;
|
|
63
|
+
border: none;
|
|
64
|
+
font-size: 1.1rem;
|
|
65
|
+
line-height: 1;
|
|
66
|
+
color: #888;
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
padding: 0 0.2rem;
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
}
|
|
71
|
+
.br-popup-btn:hover { color: #2c2c2c; }
|
|
72
|
+
.br-popup-open-tab {
|
|
73
|
+
background: none;
|
|
74
|
+
border: none;
|
|
75
|
+
font-size: 0.75rem;
|
|
76
|
+
line-height: 1;
|
|
77
|
+
color: #aaa;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
padding: 0 0.25rem;
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
display: none;
|
|
82
|
+
}
|
|
83
|
+
.br-popup-open-tab:hover { color: #2c2c2c; }
|
|
84
|
+
.br-popup[data-browse-href] .br-popup-open-tab { display: inline-block; }
|
|
85
|
+
.br-popup-body {
|
|
86
|
+
padding: 0;
|
|
87
|
+
flex: 1;
|
|
88
|
+
min-height: 0;
|
|
89
|
+
overflow: auto;
|
|
90
|
+
-webkit-overflow-scrolling: touch;
|
|
91
|
+
font-size: 0.85rem;
|
|
92
|
+
}
|
|
93
|
+
.br-popup-resize {
|
|
94
|
+
position: absolute;
|
|
95
|
+
bottom: 0; right: 0;
|
|
96
|
+
width: 28px; height: 28px;
|
|
97
|
+
cursor: nwse-resize;
|
|
98
|
+
touch-action: none;
|
|
99
|
+
border-bottom-right-radius: 5px;
|
|
100
|
+
background: repeating-linear-gradient(-45deg, transparent, transparent 2px, rgba(0,0,0,0.12) 2px, rgba(0,0,0,0.12) 3px);
|
|
101
|
+
z-index: 2;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── View menu popup ── */
|
|
105
|
+
.br-popup.view-menu {
|
|
106
|
+
width: auto;
|
|
107
|
+
min-width: 160px;
|
|
108
|
+
max-width: 300px;
|
|
109
|
+
max-height: none;
|
|
110
|
+
}
|
|
111
|
+
.br-popup.view-menu .br-popup-body { padding: 0.4rem 0.5rem; }
|
|
112
|
+
|
|
113
|
+
/* ── Sort controls ── */
|
|
114
|
+
.br-sort-bar {
|
|
115
|
+
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
gap: 0.3rem;
|
|
118
|
+
padding: 0.35rem 0.75rem;
|
|
119
|
+
border-bottom: 1px solid #e8e4dc;
|
|
120
|
+
background: #f5f1ea;
|
|
121
|
+
font-size: 0.75rem;
|
|
122
|
+
flex-shrink: 0;
|
|
123
|
+
}
|
|
124
|
+
.br-sort-bar span { color: #999; margin-right: 0.2rem; }
|
|
125
|
+
.br-sort-btn {
|
|
126
|
+
background: none;
|
|
127
|
+
border: none;
|
|
128
|
+
font-size: 0.75rem;
|
|
129
|
+
color: #888;
|
|
130
|
+
cursor: pointer;
|
|
131
|
+
padding: 0.15rem 0.4rem;
|
|
132
|
+
border-radius: 3px;
|
|
133
|
+
font-family: inherit;
|
|
134
|
+
}
|
|
135
|
+
.br-sort-btn:hover { background: #e8e0d0; color: #3a3a3a; }
|
|
136
|
+
.br-sort-btn.active { color: #8b6914; font-weight: 600; }
|
|
137
|
+
.br-sort-arrow { font-size: 0.65rem; margin-left: 0.15rem; }
|
|
138
|
+
.br-dir-count { color: #999; margin-left: auto; }
|
|
139
|
+
|
|
140
|
+
/* ── List items (directory entries, database tables, etc.) ── */
|
|
141
|
+
.br-item {
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
padding: 0.4rem 0.75rem;
|
|
145
|
+
color: #3a3a3a;
|
|
146
|
+
text-decoration: none;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
border-bottom: 1px solid #f0ece3;
|
|
149
|
+
gap: 0.5rem;
|
|
150
|
+
}
|
|
151
|
+
.br-item:last-child { border-bottom: none; }
|
|
152
|
+
.br-item:hover { background: #f0ece3; color: #8b6914; }
|
|
153
|
+
.br-dup-btn {
|
|
154
|
+
color: #27ae60;
|
|
155
|
+
font-weight: bold;
|
|
156
|
+
font-size: 1.05rem;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
flex-shrink: 0;
|
|
159
|
+
width: 1.4em;
|
|
160
|
+
text-align: center;
|
|
161
|
+
visibility: hidden;
|
|
162
|
+
line-height: 1;
|
|
163
|
+
padding: 0.15em 0;
|
|
164
|
+
margin-right: 0.25em;
|
|
165
|
+
}
|
|
166
|
+
.br-dup-btn:hover { color: #2ecc71; transform: scale(1.2); }
|
|
167
|
+
.br-item-icon { flex-shrink: 0; font-size: 0.9rem; }
|
|
168
|
+
.br-item-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
169
|
+
.br-item-meta { color: #999; font-size: 0.75rem; white-space: nowrap; display: flex; gap: 0.8rem; }
|
|
170
|
+
|
|
171
|
+
.br-list-item {
|
|
172
|
+
display: block;
|
|
173
|
+
padding: 0.45rem 0.6rem;
|
|
174
|
+
color: #3a3a3a;
|
|
175
|
+
text-decoration: none;
|
|
176
|
+
border-radius: 4px;
|
|
177
|
+
cursor: pointer;
|
|
178
|
+
border: none;
|
|
179
|
+
background: none;
|
|
180
|
+
width: 100%;
|
|
181
|
+
text-align: left;
|
|
182
|
+
font-size: 0.85rem;
|
|
183
|
+
font-family: inherit;
|
|
184
|
+
}
|
|
185
|
+
.br-list-item:hover { background: #f0ece3; color: #8b6914; }
|
|
186
|
+
.br-list-item[style] { background: color-mix(in srgb, var(--row-color) 18%, transparent); }
|
|
187
|
+
.br-list-item[style]:hover { background: color-mix(in srgb, var(--row-color) 35%, transparent); font-weight: bold; }
|
|
188
|
+
.br-record-count { opacity: 0.5; font-size: 0.8em; }
|
|
189
|
+
.br-info-icon {
|
|
190
|
+
display: inline-block;
|
|
191
|
+
width: 1.2em; height: 1.2em; line-height: 1.2em;
|
|
192
|
+
text-align: center;
|
|
193
|
+
border-radius: 50%;
|
|
194
|
+
font-size: 0.85em;
|
|
195
|
+
font-style: italic;
|
|
196
|
+
font-weight: bold;
|
|
197
|
+
font-family: serif;
|
|
198
|
+
color: #666;
|
|
199
|
+
border: 1.5px solid #999;
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
margin-right: 0.5em;
|
|
202
|
+
vertical-align: baseline;
|
|
203
|
+
flex-shrink: 0;
|
|
204
|
+
}
|
|
205
|
+
.br-info-icon:hover { color: #333; border-color: #666; background: #f0ece3; }
|
|
206
|
+
.br-list-item-muted { color: #888; font-style: italic; }
|
|
207
|
+
.br-list-item-muted:hover { background: #eee; color: #666; }
|
|
208
|
+
|
|
209
|
+
.br-group-header {
|
|
210
|
+
font-size: 0.75rem;
|
|
211
|
+
color: #999;
|
|
212
|
+
padding: 0.5rem 0.6rem 0.15rem;
|
|
213
|
+
font-family: monospace;
|
|
214
|
+
border-top: 1px solid #e0d9cc;
|
|
215
|
+
margin-top: 0.3rem;
|
|
216
|
+
}
|
|
217
|
+
.br-group-header:first-child { border-top: none; margin-top: 0; }
|
|
218
|
+
|
|
219
|
+
.br-empty { color: #999; text-align: center; padding: 2rem; font-style: italic; }
|
|
220
|
+
|
|
221
|
+
/* ── Markdown content in popup ── */
|
|
222
|
+
.br-md-content {
|
|
223
|
+
padding: 0.75rem 1rem;
|
|
224
|
+
line-height: 1.7;
|
|
225
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
226
|
+
}
|
|
227
|
+
.br-md-content > :first-child { margin-top: 0; }
|
|
228
|
+
.br-md-content h1 { font-size: 1.3rem; margin: 1.2rem 0 0.6rem; color: #3a3a3a; }
|
|
229
|
+
.br-md-content h2 {
|
|
230
|
+
font-size: 1.1rem; margin: 1.4rem 0 0.5rem; color: #3a3a3a;
|
|
231
|
+
border-bottom: 1px solid #e0d8c8; padding-bottom: 0.2rem;
|
|
232
|
+
}
|
|
233
|
+
.br-md-content h3 { font-size: 1rem; margin: 1.1rem 0 0.4rem; color: #555; }
|
|
234
|
+
.br-md-content blockquote {
|
|
235
|
+
border-left: 4px solid #d4b96a; margin: 0.8rem 0; padding: 0.4rem 1rem;
|
|
236
|
+
background: #fdfcf6; color: #4a4a4a; font-style: italic;
|
|
237
|
+
}
|
|
238
|
+
.br-md-content blockquote p { white-space: pre-wrap; margin: 0; }
|
|
239
|
+
.br-md-content a { color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a; }
|
|
240
|
+
.br-md-content a:hover { border-bottom-color: #8b6914; }
|
|
241
|
+
.br-md-content code {
|
|
242
|
+
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.88em;
|
|
243
|
+
background: #f0ece3; padding: 0.15em 0.4em; border-radius: 3px;
|
|
244
|
+
}
|
|
245
|
+
.br-md-content pre {
|
|
246
|
+
background: #2d2d2d; color: #f0f0f0; padding: 0.8rem 1rem;
|
|
247
|
+
border-radius: 6px; overflow-x: auto; font-size: 0.82rem; line-height: 1.5;
|
|
248
|
+
}
|
|
249
|
+
.br-md-content pre code { background: none; padding: 0; color: inherit; }
|
|
250
|
+
.br-md-content hr { border: none; border-top: 1px solid #e0d8c8; margin: 1.5rem 0; }
|
|
251
|
+
.br-md-content ul, .br-md-content ol { padding-left: 1.5rem; }
|
|
252
|
+
.br-md-content li { margin-bottom: 0.2rem; }
|
|
253
|
+
.br-md-content table { border-collapse: collapse; width: 100%; font-size: 0.85rem; margin: 0.8rem 0; }
|
|
254
|
+
.br-md-content th, .br-md-content td {
|
|
255
|
+
border: 1px solid #ddd; padding: 0.35rem 0.6rem; text-align: left; vertical-align: top;
|
|
256
|
+
}
|
|
257
|
+
.br-md-content th { background: #f5f0e4; font-weight: 600; color: #555; white-space: nowrap; }
|
|
258
|
+
.br-md-content tr:nth-child(even) { background: #fdfcf9; }
|
|
259
|
+
.br-md-content img { max-width: 100%; height: auto; }
|
|
260
|
+
|
|
261
|
+
/* ── Frontmatter in popup ── */
|
|
262
|
+
.br-frontmatter {
|
|
263
|
+
margin: 0.75rem 1rem;
|
|
264
|
+
border: 1px solid #e0d8c8;
|
|
265
|
+
border-radius: 6px;
|
|
266
|
+
background: #fdfcf9;
|
|
267
|
+
}
|
|
268
|
+
.br-frontmatter .frontmatter-heading {
|
|
269
|
+
padding: 0.4rem 0.75rem;
|
|
270
|
+
font-size: 0.8rem;
|
|
271
|
+
font-weight: 600;
|
|
272
|
+
color: #8b6914;
|
|
273
|
+
background: #f5f0e4;
|
|
274
|
+
border-radius: 6px 6px 0 0;
|
|
275
|
+
border-bottom: 1px solid #e0d8c8;
|
|
276
|
+
}
|
|
277
|
+
.br-frontmatter .meta-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
|
278
|
+
.br-frontmatter .meta-table th {
|
|
279
|
+
text-align: left; padding: 0.3rem 0.75rem; width: 120px;
|
|
280
|
+
font-weight: 600; color: #666; white-space: nowrap; vertical-align: top;
|
|
281
|
+
}
|
|
282
|
+
.br-frontmatter .meta-table td { padding: 0.3rem 0.75rem; word-break: break-word; vertical-align: top; }
|
|
283
|
+
.br-frontmatter .meta-table tr:nth-child(even) { background: #faf8f2; }
|
|
284
|
+
|
|
285
|
+
/* Wiki links */
|
|
286
|
+
a.wiki-link { color: #6a8e3e; border-bottom: 1px dashed #6a8e3e; margin: 0 0.2rem; }
|
|
287
|
+
span.wiki-link.broken { color: #c44; border-bottom: 1px dashed #c44; margin: 0 0.2rem; }
|
|
288
|
+
|
|
289
|
+
/* ── Code view in popup ── */
|
|
290
|
+
.br-code-wrap { padding: 0; }
|
|
291
|
+
.br-code-toolbar {
|
|
292
|
+
display: flex; gap: 0.5rem; align-items: center;
|
|
293
|
+
padding: 0.35rem 0.75rem; background: #f5f1ea; border-bottom: 1px solid #e8e4dc;
|
|
294
|
+
font-size: 0.75rem; color: #999; flex-shrink: 0;
|
|
295
|
+
}
|
|
296
|
+
.br-code-view {
|
|
297
|
+
background: #2d2d2d; color: #f0f0f0; padding: 1rem 1.2rem;
|
|
298
|
+
overflow: auto; font-family: "SF Mono", Menlo, Consolas, monospace;
|
|
299
|
+
font-size: 0.8rem; line-height: 1.5; flex: 1; min-height: 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* Rouge Monokai syntax highlighting (popup + full-screen) */
|
|
303
|
+
<%= Rouge::Themes::Monokai.render(scope: '.br-code-view').gsub(/^\.br-code-view \{\n.*?background-color:.*?\n\}$/m, '') %>
|
|
304
|
+
<%= Rouge::Themes::Monokai.render(scope: '.highlight').gsub(/^\.highlight \{\n.*?background-color:.*?\n\}$/m, '') %>
|
|
305
|
+
|
|
306
|
+
/* ── Download / external link in popup ── */
|
|
307
|
+
.br-link-body {
|
|
308
|
+
display: flex; flex-direction: column; align-items: center;
|
|
309
|
+
justify-content: center; padding: 2rem; gap: 0.5rem; min-height: 120px;
|
|
310
|
+
}
|
|
311
|
+
.br-link-body a {
|
|
312
|
+
color: #8b6914; text-decoration: none; border-bottom: 1px solid #d4b96a;
|
|
313
|
+
font-size: 0.95rem;
|
|
314
|
+
}
|
|
315
|
+
.br-link-body a:hover { border-bottom-color: #8b6914; }
|
|
316
|
+
.br-link-body .br-link-size { color: #999; font-size: 0.8rem; }
|
|
317
|
+
|
|
318
|
+
/* ── CSV data table ── */
|
|
319
|
+
.br-search-wrap { padding: 0; margin-bottom: 0.5rem; flex-shrink: 0; display: flex; gap: 0.35rem; align-items: center; }
|
|
320
|
+
.br-search {
|
|
321
|
+
flex: 1; padding: 0.35rem 0.5rem; border: 1px solid #d4b96a;
|
|
322
|
+
border-radius: 12px; font-size: 0.8rem;
|
|
323
|
+
font-family: "SF Mono", Menlo, Consolas, monospace;
|
|
324
|
+
background: #fff; color: #3a3a3a; outline: none;
|
|
325
|
+
}
|
|
326
|
+
.br-search:focus { border-color: #8b6914; box-shadow: 0 0 0 2px rgba(139,105,20,0.15); }
|
|
327
|
+
.br-search::placeholder { color: #bbb; }
|
|
328
|
+
.br-col-filter-toggle, .br-mode-toggle, .br-validate-btn, .br-rownum-toggle, .br-save-default-btn {
|
|
329
|
+
background: none; border: 1px solid #d4b96a; border-radius: 4px;
|
|
330
|
+
cursor: pointer; font-size: 0.8rem; line-height: 1; padding: 0.35rem 0.45rem;
|
|
331
|
+
color: #8b6914; font-family: "SF Mono", Menlo, Consolas, monospace;
|
|
332
|
+
}
|
|
333
|
+
.br-col-filter-toggle:hover, .br-mode-toggle:hover, .br-validate-btn:hover, .br-rownum-toggle:hover, .br-save-default-btn:hover { background: #f0ece3; }
|
|
334
|
+
.br-rownum-toggle.active, .br-save-default-btn.active { color: #2c6e49; border-color: #2c6e49; }
|
|
335
|
+
.br-rownum-toggle.active:hover, .br-save-default-btn.active:hover { background: #eaf5ef; }
|
|
336
|
+
.br-save-default-btn.loaded { outline: 2px solid #27ae60; outline-offset: -1px; }
|
|
337
|
+
.br-mode-toggle.edit-mode { color: #c0392b; border-color: #c0392b; }
|
|
338
|
+
.br-mode-toggle.edit-mode:hover { background: #fdf0ef; }
|
|
339
|
+
.br-col-filter-row input {
|
|
340
|
+
width: 100%; padding: 0.2rem 0.35rem; border: 1px solid #d4b96a;
|
|
341
|
+
border-radius: 10px; font-size: 0.75rem; font-family: inherit;
|
|
342
|
+
background: #fff; color: #3a3a3a; outline: none; box-sizing: border-box;
|
|
343
|
+
}
|
|
344
|
+
.br-col-filter-row input:focus { border-color: #8b6914; box-shadow: 0 0 0 2px rgba(139,105,20,0.15); }
|
|
345
|
+
.br-col-filter-row input::placeholder { color: #ccc; }
|
|
346
|
+
.br-col-filter-row td {
|
|
347
|
+
padding: 0.25rem 0.4rem; background: #f8f5ef; border-bottom: 1px solid #d4b96a;
|
|
348
|
+
position: sticky; top: 1.65rem; z-index: 1;
|
|
349
|
+
}
|
|
350
|
+
.br-table-wrap { overflow: auto; flex: 1; min-height: 0; }
|
|
351
|
+
.br-data-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
|
352
|
+
.br-data-table th {
|
|
353
|
+
background: #f0ece3; font-weight: 600; text-align: left;
|
|
354
|
+
padding: 0.4rem 0.6rem; border-bottom: 2px solid #d4b96a;
|
|
355
|
+
position: sticky; top: 0; white-space: nowrap; cursor: pointer;
|
|
356
|
+
user-select: none; z-index: 1; overflow: hidden; text-overflow: ellipsis;
|
|
357
|
+
}
|
|
358
|
+
.br-data-table th:hover { background: #e8e0d0; }
|
|
359
|
+
.br-col-resize {
|
|
360
|
+
position: absolute; right: -3px; top: 0; bottom: 0; width: 6px;
|
|
361
|
+
cursor: col-resize; z-index: 2; background: transparent;
|
|
362
|
+
}
|
|
363
|
+
.br-col-resize:hover, .br-col-resize.active { background: rgba(139,105,20,0.3); }
|
|
364
|
+
.br-data-table th .sort-arrow {
|
|
365
|
+
display: inline-block; width: 1em; text-align: center;
|
|
366
|
+
color: #bbb; font-size: 0.7rem; margin-left: 0.3rem;
|
|
367
|
+
}
|
|
368
|
+
.br-data-table th.sort-asc .sort-arrow,
|
|
369
|
+
.br-data-table th.sort-desc .sort-arrow { color: #8b6914; }
|
|
370
|
+
.br-data-table td {
|
|
371
|
+
padding: 0.35rem 0.6rem; border-bottom: 1px solid #e8e4dc;
|
|
372
|
+
white-space: nowrap;
|
|
373
|
+
}
|
|
374
|
+
.br-data-table td { cursor: pointer; }
|
|
375
|
+
.br-data-table.edit-mode td { cursor: text; }
|
|
376
|
+
.br-data-table tr:hover td { background: #f8f5ef; }
|
|
377
|
+
.br-data-table td.null { color: #bbb; font-style: italic; }
|
|
378
|
+
.br-data-table td.fk-link { color: #2a6496; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; }
|
|
379
|
+
.br-data-table td.fk-link:hover { color: #1a4a70; }
|
|
380
|
+
.br-reverse-refs { white-space: nowrap; }
|
|
381
|
+
.br-rr-link {
|
|
382
|
+
cursor: pointer; text-decoration: none;
|
|
383
|
+
font-size: 0.7rem; padding: 0.15rem 0.45rem; border-radius: 9px;
|
|
384
|
+
color: #fff; display: inline-block; font-weight: 500; letter-spacing: 0.01em;
|
|
385
|
+
background: #6a5a8e;
|
|
386
|
+
}
|
|
387
|
+
.br-rr-link:hover { filter: brightness(1.15); }
|
|
388
|
+
.br-data-table tr.br-row-selected td { background: #e8f0fe; }
|
|
389
|
+
.br-data-table tr.br-row-selected:hover td { background: #d4e4fc; }
|
|
390
|
+
.br-data-table tr.br-row-dirty td { background: #fef9e7; }
|
|
391
|
+
.br-data-table tr.br-row-dirty:hover td { background: #fdf3d0; }
|
|
392
|
+
.br-data-table tr.br-row-invalid td { background: #fde8e8; }
|
|
393
|
+
.br-data-table tr.br-row-invalid:hover td { background: #fad4d4; }
|
|
394
|
+
.br-data-table td.br-cell-match { background: #e8f5e9 !important; }
|
|
395
|
+
.br-data-table tr.br-row-match td { background: #e8f5e9 !important; }
|
|
396
|
+
.br-data-table tr.br-row-match:hover td { background: #c8e6c9 !important; }
|
|
397
|
+
.br-highlight-dropdown {
|
|
398
|
+
position: absolute; z-index: 10000; background: #faf8f4; border: 1px solid #d4b96a;
|
|
399
|
+
border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); overflow: hidden;
|
|
400
|
+
font-size: 0.8rem; min-width: 160px;
|
|
401
|
+
}
|
|
402
|
+
.br-highlight-dropdown-item {
|
|
403
|
+
display: block; width: 100%; padding: 0.4rem 0.75rem; border: none; background: none;
|
|
404
|
+
text-align: left; cursor: pointer; font-family: inherit; font-size: 0.8rem; color: #3a3a3a;
|
|
405
|
+
}
|
|
406
|
+
.br-highlight-dropdown-item:hover { background: #eee8dc; }
|
|
407
|
+
.br-highlight-dropdown-item.active { background: #e8f5e9; font-weight: 600; }
|
|
408
|
+
.br-val-cell { text-align: center; padding: 0 0.2rem !important; cursor: pointer; }
|
|
409
|
+
.br-val-icon { font-size: 0.85rem; }
|
|
410
|
+
.br-cell-edit {
|
|
411
|
+
width: 100%; min-width: 60px; padding: 0.15rem 0.3rem;
|
|
412
|
+
border: 1px solid #d4b96a; border-radius: 3px; font-size: 0.8rem;
|
|
413
|
+
font-family: inherit; background: #fff; outline: none;
|
|
414
|
+
margin: -0.15rem -0.3rem;
|
|
415
|
+
}
|
|
416
|
+
.br-cell-edit:focus { border-color: #8b6914; box-shadow: 0 0 0 2px rgba(139,105,20,0.15); }
|
|
417
|
+
.br-cell-edit.invalid { border-color: #c0392b; outline: 2px solid #c0392b; outline-offset: -1px; }
|
|
418
|
+
.br-row-actions { white-space: nowrap; text-align: center; }
|
|
419
|
+
.br-row-actions button {
|
|
420
|
+
background: none; border: none; cursor: pointer; font-size: 1rem;
|
|
421
|
+
padding: 0 0.25rem; line-height: 1;
|
|
422
|
+
}
|
|
423
|
+
.br-row-save { color: #27ae60; }
|
|
424
|
+
.br-row-save:hover { color: #1e8449; }
|
|
425
|
+
.br-row-save.has-errors { color: #c0392b; }
|
|
426
|
+
.br-row-save.has-errors:hover { color: #96281b; }
|
|
427
|
+
.br-row-cancel { color: #c0392b; }
|
|
428
|
+
.br-row-cancel:hover { color: #96281b; }
|
|
429
|
+
.br-data-table td.cell-invalid {
|
|
430
|
+
outline: 2px solid #c0392b;
|
|
431
|
+
outline-offset: -2px;
|
|
432
|
+
}
|
|
433
|
+
.br-validation-tooltip {
|
|
434
|
+
position: fixed;
|
|
435
|
+
z-index: 31000;
|
|
436
|
+
background: #2c0b0b;
|
|
437
|
+
color: #f8d7d7;
|
|
438
|
+
border: 1px solid #c0392b;
|
|
439
|
+
border-radius: 6px;
|
|
440
|
+
padding: 0.5rem 0.75rem;
|
|
441
|
+
font-size: 0.8rem;
|
|
442
|
+
max-width: 320px;
|
|
443
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
|
444
|
+
line-height: 1.4;
|
|
445
|
+
}
|
|
446
|
+
.br-validation-tooltip div { margin: 0.15rem 0; }
|
|
447
|
+
|
|
448
|
+
/* ── Context menu ── */
|
|
449
|
+
.br-context-menu {
|
|
450
|
+
position: fixed;
|
|
451
|
+
z-index: 30000;
|
|
452
|
+
background: #faf8f4;
|
|
453
|
+
border: 1px solid #d4b96a;
|
|
454
|
+
border-radius: 6px;
|
|
455
|
+
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
456
|
+
min-width: 160px;
|
|
457
|
+
padding: 0.3rem 0;
|
|
458
|
+
font-size: 0.82rem;
|
|
459
|
+
}
|
|
460
|
+
.br-context-menu-item {
|
|
461
|
+
display: block;
|
|
462
|
+
width: 100%;
|
|
463
|
+
padding: 0.4rem 0.75rem;
|
|
464
|
+
background: none;
|
|
465
|
+
border: none;
|
|
466
|
+
text-align: left;
|
|
467
|
+
cursor: pointer;
|
|
468
|
+
color: #c0392b;
|
|
469
|
+
font-family: inherit;
|
|
470
|
+
font-size: inherit;
|
|
471
|
+
}
|
|
472
|
+
.br-context-menu-item:hover { background: #fdf0ef; }
|
|
473
|
+
.br-context-menu-item.default { color: #3a3a3a; }
|
|
474
|
+
.br-context-menu-item.default:hover { background: #eee8dc; }
|
|
475
|
+
.br-context-menu-sep { height: 1px; background: #d4b96a; margin: 0.25rem 0; opacity: 0.5; }
|
|
476
|
+
.br-context-menu-label { padding: 0.3rem 0.75rem; color: #888; font-size: 0.75rem; }
|
|
477
|
+
.br-context-menu-form { padding: 0.3rem 0.75rem; }
|
|
478
|
+
.br-context-menu-form input[type="text"] {
|
|
479
|
+
width: 100%; box-sizing: border-box; padding: 0.3rem 0.4rem;
|
|
480
|
+
border: 1px solid #d4b96a; border-radius: 4px; font-family: inherit; font-size: inherit;
|
|
481
|
+
background: #fff; color: #3a3a3a; margin-bottom: 0.35rem;
|
|
482
|
+
}
|
|
483
|
+
.br-context-menu-form input[type="text"]:focus { outline: none; border-color: #b8860b; }
|
|
484
|
+
.br-context-menu-form .br-form-buttons { display: flex; gap: 0.35rem; }
|
|
485
|
+
.br-context-menu-form .br-form-buttons button {
|
|
486
|
+
flex: 1; padding: 0.25rem 0.5rem; border: 1px solid #d4b96a; border-radius: 4px;
|
|
487
|
+
font-family: inherit; font-size: inherit; cursor: pointer;
|
|
488
|
+
}
|
|
489
|
+
.br-context-menu-form .br-btn-save { background: #eee8dc; color: #3a3a3a; }
|
|
490
|
+
.br-context-menu-form .br-btn-save:hover { background: #e0d8c8; }
|
|
491
|
+
.br-context-menu-form .br-btn-cancel { background: #faf8f4; color: #888; }
|
|
492
|
+
.br-context-menu-form .br-btn-cancel:hover { background: #f0ece4; }
|
|
493
|
+
|
|
494
|
+
/* ── Row editor ── */
|
|
495
|
+
.br-row-editor { display: flex; flex-direction: column; gap: 0; }
|
|
496
|
+
.br-row-editor-field {
|
|
497
|
+
display: flex; align-items: center; gap: 0.5rem;
|
|
498
|
+
padding: 0.4rem 0.6rem;
|
|
499
|
+
border-bottom: 1px solid #e8e4dc;
|
|
500
|
+
}
|
|
501
|
+
.br-row-editor-field:last-child { border-bottom: none; }
|
|
502
|
+
.br-row-editor-label {
|
|
503
|
+
width: 140px; min-width: 140px; font-weight: 600; font-size: 0.8rem;
|
|
504
|
+
color: #555; text-align: right; padding-right: 0.4rem;
|
|
505
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
506
|
+
cursor: default;
|
|
507
|
+
}
|
|
508
|
+
.br-row-editor-label.required::after { content: ' *'; color: #c0392b; font-weight: 400; }
|
|
509
|
+
.br-row-editor-input { flex: 1; min-width: 0; }
|
|
510
|
+
.br-row-editor-input input,
|
|
511
|
+
.br-row-editor-input select {
|
|
512
|
+
width: 100%; padding: 0.3rem 0.4rem;
|
|
513
|
+
border: 1px solid #d4b96a; border-radius: 4px;
|
|
514
|
+
font-family: inherit; font-size: 0.82rem;
|
|
515
|
+
background: #fff; color: #3a3a3a; outline: none;
|
|
516
|
+
}
|
|
517
|
+
.br-row-editor-input input:focus,
|
|
518
|
+
.br-row-editor-input select:focus {
|
|
519
|
+
border-color: #8b6914; box-shadow: 0 0 0 2px rgba(139,105,20,0.15);
|
|
520
|
+
}
|
|
521
|
+
.br-row-editor-input input.dirty,
|
|
522
|
+
.br-row-editor-input select.dirty { background: #fef9e7; }
|
|
523
|
+
.br-row-editor-type-icon {
|
|
524
|
+
width: 22px; min-width: 22px; text-align: center;
|
|
525
|
+
font-size: 0.75rem; color: #999; user-select: none;
|
|
526
|
+
font-family: "SF Mono", "Cascadia Code", "Consolas", monospace;
|
|
527
|
+
}
|
|
528
|
+
.br-row-editor-actions {
|
|
529
|
+
display: flex; gap: 0.5rem; justify-content: flex-end;
|
|
530
|
+
padding: 0.5rem 0.6rem; border-top: 1px solid #e0d8c8;
|
|
531
|
+
}
|
|
532
|
+
.br-row-editor-actions button {
|
|
533
|
+
padding: 0.35rem 1rem; border: 1px solid #d4b96a; border-radius: 4px;
|
|
534
|
+
font-family: inherit; font-size: 0.82rem; cursor: pointer;
|
|
535
|
+
}
|
|
536
|
+
.br-row-editor-actions .br-re-save {
|
|
537
|
+
background: #27ae60; color: #fff; border-color: #219a52;
|
|
538
|
+
}
|
|
539
|
+
.br-row-editor-actions .br-re-save:hover { background: #219a52; }
|
|
540
|
+
.br-row-editor-actions .br-re-save:disabled {
|
|
541
|
+
background: #aaa; border-color: #999; cursor: default;
|
|
542
|
+
}
|
|
543
|
+
.br-row-editor-actions .br-re-save.has-errors {
|
|
544
|
+
background: #c0392b; border-color: #96281b;
|
|
545
|
+
}
|
|
546
|
+
.br-row-editor-actions .br-re-save.has-errors:hover { background: #96281b; }
|
|
547
|
+
.br-row-editor-actions .br-re-save.has-errors:disabled {
|
|
548
|
+
background: #c0392b; border-color: #96281b; cursor: pointer; opacity: 0.9;
|
|
549
|
+
}
|
|
550
|
+
.br-row-editor-input input.invalid,
|
|
551
|
+
.br-row-editor-input select.invalid {
|
|
552
|
+
border-color: #c0392b; outline: 2px solid #c0392b; outline-offset: -1px;
|
|
553
|
+
}
|
|
554
|
+
.br-row-editor-actions .br-re-cancel {
|
|
555
|
+
background: #faf8f4; color: #666;
|
|
556
|
+
}
|
|
557
|
+
.br-row-editor-actions .br-re-cancel:hover { background: #f0ece4; }
|
|
558
|
+
.br-row-editor-fk-display {
|
|
559
|
+
font-size: 0.75rem; color: #2a6496; margin-left: 0.3rem;
|
|
560
|
+
font-style: italic;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/* ── Tab bar ── */
|
|
564
|
+
.br-tab-bar {
|
|
565
|
+
display: flex;
|
|
566
|
+
align-items: stretch;
|
|
567
|
+
background: #111120;
|
|
568
|
+
height: 34px;
|
|
569
|
+
flex-shrink: 0;
|
|
570
|
+
user-select: none;
|
|
571
|
+
z-index: 20000;
|
|
572
|
+
position: relative;
|
|
573
|
+
padding-top: 4px;
|
|
574
|
+
}
|
|
575
|
+
.br-tab {
|
|
576
|
+
display: flex;
|
|
577
|
+
align-items: center;
|
|
578
|
+
padding: 0 1rem;
|
|
579
|
+
background: #44445e;
|
|
580
|
+
color: #dddde8;
|
|
581
|
+
font-size: 0.82rem;
|
|
582
|
+
font-weight: 500;
|
|
583
|
+
cursor: pointer;
|
|
584
|
+
white-space: nowrap;
|
|
585
|
+
min-width: 50px;
|
|
586
|
+
max-width: 200px;
|
|
587
|
+
position: relative;
|
|
588
|
+
letter-spacing: 0.02em;
|
|
589
|
+
transition: background 0.15s, color 0.15s;
|
|
590
|
+
border-radius: 8px 8px 0 0;
|
|
591
|
+
margin-right: 1px;
|
|
592
|
+
}
|
|
593
|
+
/* Vertical separator between inactive tabs */
|
|
594
|
+
.br-tab:not(.active) + .br-tab:not(.active)::before {
|
|
595
|
+
content: '';
|
|
596
|
+
position: absolute;
|
|
597
|
+
left: -1px;
|
|
598
|
+
top: 20%;
|
|
599
|
+
height: 60%;
|
|
600
|
+
width: 1px;
|
|
601
|
+
background: #808098;
|
|
602
|
+
}
|
|
603
|
+
.br-tab:hover { color: #f0f0f4; background: #505068; }
|
|
604
|
+
.br-tab.active {
|
|
605
|
+
background: #6e6e88;
|
|
606
|
+
color: #f4f0e6;
|
|
607
|
+
font-weight: 700;
|
|
608
|
+
}
|
|
609
|
+
/* Hide separator next to active tab */
|
|
610
|
+
.br-tab.active + .br-tab::before { display: none; }
|
|
611
|
+
.br-tab-label {
|
|
612
|
+
overflow: hidden;
|
|
613
|
+
text-overflow: ellipsis;
|
|
614
|
+
}
|
|
615
|
+
.br-tab-add {
|
|
616
|
+
display: flex;
|
|
617
|
+
align-items: center;
|
|
618
|
+
justify-content: center;
|
|
619
|
+
padding: 0 0.7rem;
|
|
620
|
+
color: #555;
|
|
621
|
+
font-size: 1.2rem;
|
|
622
|
+
cursor: pointer;
|
|
623
|
+
background: none;
|
|
624
|
+
border: none;
|
|
625
|
+
font-family: inherit;
|
|
626
|
+
}
|
|
627
|
+
.br-tab-add:hover { color: #d4b96a; }
|
|
628
|
+
</style>
|
|
629
|
+
</head>
|
|
630
|
+
<body>
|
|
631
|
+
|
|
632
|
+
<div class="br-tab-bar" id="br-tab-bar">
|
|
633
|
+
<button class="br-tab-add" id="br-tab-add" title="New tab">+</button>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<script>
|
|
637
|
+
(function() {
|
|
638
|
+
'use strict';
|
|
639
|
+
|
|
640
|
+
var TAB_BAR_HEIGHT = 36;
|
|
641
|
+
var zCounter = 500;
|
|
642
|
+
var startMode = '<%= @start_mode %>';
|
|
643
|
+
var rootTitle = '<%= h(@root_title) %>';
|
|
644
|
+
var csvDatabases = <%= @csv_databases %>;
|
|
645
|
+
|
|
646
|
+
// ── Tab management ──
|
|
647
|
+
|
|
648
|
+
var tabs = [];
|
|
649
|
+
var activeTabId = null;
|
|
650
|
+
var tabCounter = 0;
|
|
651
|
+
var hasCsvDatabases = Array.isArray(csvDatabases) && csvDatabases.length > 0;
|
|
652
|
+
|
|
653
|
+
function createTab(name, mode) {
|
|
654
|
+
var id = ++tabCounter;
|
|
655
|
+
var tab = { id: id, name: name || String(id), mode: mode || 'pending' };
|
|
656
|
+
tabs.push(tab);
|
|
657
|
+
renderTabBar();
|
|
658
|
+
return tab;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function switchTab(id) {
|
|
662
|
+
if (activeTabId === id) return;
|
|
663
|
+
activeTabId = id;
|
|
664
|
+
// Hide all popups, show only those belonging to the active tab
|
|
665
|
+
document.querySelectorAll('.br-popup:not(.view-menu)').forEach(function(el) {
|
|
666
|
+
if (el.getAttribute('data-tab-id') === String(id)) {
|
|
667
|
+
el.style.display = '';
|
|
668
|
+
} else {
|
|
669
|
+
el.style.display = 'none';
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
renderTabBar();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function deleteTab(id) {
|
|
676
|
+
if (tabs.length <= 1) return;
|
|
677
|
+
// Remove all popups belonging to this tab
|
|
678
|
+
document.querySelectorAll('.br-popup[data-tab-id="' + id + '"]').forEach(function(el) { el.remove(); });
|
|
679
|
+
tabs = tabs.filter(function(t) { return t.id !== id; });
|
|
680
|
+
if (activeTabId === id) {
|
|
681
|
+
switchTab(tabs[0].id);
|
|
682
|
+
}
|
|
683
|
+
renderTabBar();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function findTab(id) {
|
|
687
|
+
return tabs.find(function(t) { return t.id === id; });
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function activeTab() {
|
|
691
|
+
return findTab(activeTabId);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function renameTab(id, name) {
|
|
695
|
+
var tab = findTab(id);
|
|
696
|
+
if (tab) { tab.name = name; renderTabBar(); }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function renderTabBar() {
|
|
700
|
+
var bar = document.getElementById('br-tab-bar');
|
|
701
|
+
var addBtn = document.getElementById('br-tab-add');
|
|
702
|
+
// Remove existing tab elements (but not the add button)
|
|
703
|
+
Array.from(bar.querySelectorAll('.br-tab')).forEach(function(el) { el.remove(); });
|
|
704
|
+
tabs.forEach(function(tab) {
|
|
705
|
+
var el = document.createElement('div');
|
|
706
|
+
el.className = 'br-tab' + (tab.id === activeTabId ? ' active' : '');
|
|
707
|
+
el.setAttribute('data-tab-btn-id', tab.id);
|
|
708
|
+
var label = document.createElement('span');
|
|
709
|
+
label.className = 'br-tab-label';
|
|
710
|
+
label.textContent = tab.name;
|
|
711
|
+
el.appendChild(label);
|
|
712
|
+
el.addEventListener('click', function() { switchTab(tab.id); });
|
|
713
|
+
el.addEventListener('contextmenu', function(e) {
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
e.stopPropagation();
|
|
716
|
+
showTabContextMenu(tab.id, e.clientX, e.clientY);
|
|
717
|
+
});
|
|
718
|
+
bar.insertBefore(el, addBtn);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function showTabContextMenu(tabId, x, y) {
|
|
723
|
+
var existing = document.querySelector('.br-context-menu');
|
|
724
|
+
if (existing) existing.remove();
|
|
725
|
+
if (tabs.length <= 1) return; // can't delete last tab
|
|
726
|
+
|
|
727
|
+
var menu = document.createElement('div');
|
|
728
|
+
menu.className = 'br-context-menu';
|
|
729
|
+
menu.style.left = x + 'px';
|
|
730
|
+
menu.style.top = y + 'px';
|
|
731
|
+
|
|
732
|
+
var deleteBtn = document.createElement('button');
|
|
733
|
+
deleteBtn.className = 'br-context-menu-item';
|
|
734
|
+
deleteBtn.textContent = 'Close tab';
|
|
735
|
+
deleteBtn.addEventListener('click', function() {
|
|
736
|
+
menu.remove();
|
|
737
|
+
deleteTab(tabId);
|
|
738
|
+
});
|
|
739
|
+
menu.appendChild(deleteBtn);
|
|
740
|
+
|
|
741
|
+
document.body.appendChild(menu);
|
|
742
|
+
fitMenuInViewport(menu);
|
|
743
|
+
|
|
744
|
+
setTimeout(function() {
|
|
745
|
+
document.addEventListener('mousedown', function handler(ev) {
|
|
746
|
+
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', handler); }
|
|
747
|
+
});
|
|
748
|
+
}, 0);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function initNewTab(tab) {
|
|
752
|
+
switchTab(tab.id);
|
|
753
|
+
if (tab.mode === 'csv') {
|
|
754
|
+
showDatabaseList(csvDatabases, { noClose: true });
|
|
755
|
+
} else {
|
|
756
|
+
loadContent('', { title: rootTitle, noClose: true, pos: { x: 8, y: TAB_BAR_HEIGHT + 8 } });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
document.getElementById('br-tab-add').addEventListener('click', function(e) {
|
|
761
|
+
e.stopPropagation();
|
|
762
|
+
if (hasCsvDatabases) {
|
|
763
|
+
// Show menu to choose csv-browser or browser
|
|
764
|
+
var existing = document.querySelector('.br-context-menu');
|
|
765
|
+
if (existing) existing.remove();
|
|
766
|
+
|
|
767
|
+
var menu = document.createElement('div');
|
|
768
|
+
menu.className = 'br-context-menu';
|
|
769
|
+
var rect = e.target.getBoundingClientRect();
|
|
770
|
+
menu.style.left = rect.left + 'px';
|
|
771
|
+
menu.style.top = (rect.bottom + 2) + 'px';
|
|
772
|
+
|
|
773
|
+
var csvBtn = document.createElement('button');
|
|
774
|
+
csvBtn.className = 'br-context-menu-item default';
|
|
775
|
+
csvBtn.textContent = 'CSV Browser';
|
|
776
|
+
csvBtn.addEventListener('click', function() {
|
|
777
|
+
menu.remove();
|
|
778
|
+
var tab = createTab(null, 'csv');
|
|
779
|
+
initNewTab(tab);
|
|
780
|
+
});
|
|
781
|
+
menu.appendChild(csvBtn);
|
|
782
|
+
|
|
783
|
+
var browseBtn = document.createElement('button');
|
|
784
|
+
browseBtn.className = 'br-context-menu-item default';
|
|
785
|
+
browseBtn.textContent = 'File Browser';
|
|
786
|
+
browseBtn.addEventListener('click', function() {
|
|
787
|
+
menu.remove();
|
|
788
|
+
var tab = createTab(null, 'directory');
|
|
789
|
+
initNewTab(tab);
|
|
790
|
+
});
|
|
791
|
+
menu.appendChild(browseBtn);
|
|
792
|
+
|
|
793
|
+
document.body.appendChild(menu);
|
|
794
|
+
fitMenuInViewport(menu);
|
|
795
|
+
|
|
796
|
+
setTimeout(function() {
|
|
797
|
+
document.addEventListener('mousedown', function handler(ev) {
|
|
798
|
+
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', handler); }
|
|
799
|
+
});
|
|
800
|
+
}, 0);
|
|
801
|
+
} else {
|
|
802
|
+
// No CSV databases — just open a file browser tab
|
|
803
|
+
var tab = createTab(null, 'directory');
|
|
804
|
+
initNewTab(tab);
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ── Global context-menu dismiss (registered once) ──
|
|
809
|
+
|
|
810
|
+
function dismissContextMenu() {
|
|
811
|
+
var existing = document.querySelector('.br-context-menu');
|
|
812
|
+
if (existing) existing.remove();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
document.addEventListener('click', dismissContextMenu);
|
|
816
|
+
document.addEventListener('contextmenu', function(e) {
|
|
817
|
+
if (!e.defaultPrevented && !e.target.closest('.br-data-table')) dismissContextMenu();
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// ── Popup creation ──
|
|
821
|
+
|
|
822
|
+
function createPopup(opts) {
|
|
823
|
+
var el = document.createElement('div');
|
|
824
|
+
el.className = 'br-popup' + (opts.pinned ? ' pinned' : '') + (opts.wide ? ' wide' : '') + (opts.viewMenu ? ' view-menu' : '');
|
|
825
|
+
if (opts.id) el.setAttribute('data-popup-id', opts.id);
|
|
826
|
+
|
|
827
|
+
var header = document.createElement('div');
|
|
828
|
+
header.className = 'br-popup-header';
|
|
829
|
+
if (opts.color) {
|
|
830
|
+
header.style.background = opts.color;
|
|
831
|
+
header.style.borderBottomColor = opts.color;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
var title = document.createElement('span');
|
|
835
|
+
title.className = 'br-popup-title';
|
|
836
|
+
title.textContent = opts.title || '';
|
|
837
|
+
if (opts.color) title.style.color = '#fff';
|
|
838
|
+
header.appendChild(title);
|
|
839
|
+
|
|
840
|
+
var openTabBtn = document.createElement('button');
|
|
841
|
+
openTabBtn.className = 'br-popup-open-tab';
|
|
842
|
+
openTabBtn.title = 'Open in new tab';
|
|
843
|
+
openTabBtn.innerHTML = '↗';
|
|
844
|
+
if (opts.color) openTabBtn.style.color = 'rgba(255,255,255,0.8)';
|
|
845
|
+
openTabBtn.onclick = function(e) {
|
|
846
|
+
e.stopPropagation();
|
|
847
|
+
var href = el.getAttribute('data-browse-href');
|
|
848
|
+
if (href) window.open(href, '_blank');
|
|
849
|
+
};
|
|
850
|
+
header.appendChild(openTabBtn);
|
|
851
|
+
|
|
852
|
+
if (!opts.noClose) {
|
|
853
|
+
var closeBtn = document.createElement('button');
|
|
854
|
+
closeBtn.className = 'br-popup-btn';
|
|
855
|
+
closeBtn.innerHTML = '×';
|
|
856
|
+
if (opts.color) closeBtn.style.color = 'rgba(255,255,255,0.8)';
|
|
857
|
+
closeBtn.onclick = function() { el.remove(); };
|
|
858
|
+
header.appendChild(closeBtn);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
el.appendChild(header);
|
|
862
|
+
|
|
863
|
+
var body = document.createElement('div');
|
|
864
|
+
body.className = 'br-popup-body';
|
|
865
|
+
if (opts.html) body.innerHTML = opts.html;
|
|
866
|
+
el.appendChild(body);
|
|
867
|
+
|
|
868
|
+
if (!opts.viewMenu) {
|
|
869
|
+
var resize = document.createElement('div');
|
|
870
|
+
resize.className = 'br-popup-resize';
|
|
871
|
+
el.appendChild(resize);
|
|
872
|
+
makeResizable(el, resize);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
el.style.zIndex = ++zCounter;
|
|
876
|
+
el.addEventListener('mousedown', function() { el.style.zIndex = ++zCounter; });
|
|
877
|
+
|
|
878
|
+
if (opts.x !== undefined && opts.y !== undefined) {
|
|
879
|
+
el.style.left = opts.x + 'px';
|
|
880
|
+
el.style.top = opts.y + 'px';
|
|
881
|
+
} else {
|
|
882
|
+
el.style.left = '50%';
|
|
883
|
+
el.style.top = '50%';
|
|
884
|
+
el.style.transform = 'translate(-50%, -50%)';
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (opts.pinned) el.classList.add('pinned');
|
|
888
|
+
if (opts.width) el.style.width = opts.width + 'px';
|
|
889
|
+
if (opts.height) el.style.height = opts.height + 'px';
|
|
890
|
+
|
|
891
|
+
makeDraggable(el, header);
|
|
892
|
+
if (activeTabId) el.setAttribute('data-tab-id', activeTabId);
|
|
893
|
+
document.body.appendChild(el);
|
|
894
|
+
|
|
895
|
+
if (opts.x === undefined) {
|
|
896
|
+
var rect = el.getBoundingClientRect();
|
|
897
|
+
el.style.left = rect.left + 'px';
|
|
898
|
+
el.style.top = rect.top + 'px';
|
|
899
|
+
el.style.transform = '';
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return el;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function applyPopupColor(popup, color) {
|
|
906
|
+
var header = popup.querySelector('.br-popup-header');
|
|
907
|
+
if (!header) return;
|
|
908
|
+
header.style.background = color;
|
|
909
|
+
header.style.borderBottomColor = color;
|
|
910
|
+
header.querySelector('.br-popup-title').style.color = '#fff';
|
|
911
|
+
header.querySelectorAll('.br-popup-btn, .br-popup-open-tab').forEach(function(btn) {
|
|
912
|
+
btn.style.color = 'rgba(255,255,255,0.8)';
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function makeDraggable(el, handle) {
|
|
917
|
+
var startX, startY, origLeft, origTop;
|
|
918
|
+
function onDown(e) {
|
|
919
|
+
if (e.target.closest('.br-popup-btn')) return;
|
|
920
|
+
e.preventDefault();
|
|
921
|
+
var t = e.touches ? e.touches[0] : e;
|
|
922
|
+
startX = t.clientX; startY = t.clientY;
|
|
923
|
+
var rect = el.getBoundingClientRect();
|
|
924
|
+
origLeft = rect.left; origTop = rect.top;
|
|
925
|
+
el.style.width = rect.width + 'px';
|
|
926
|
+
el.style.height = rect.height + 'px';
|
|
927
|
+
el.style.maxWidth = 'none';
|
|
928
|
+
el.style.maxHeight = 'none';
|
|
929
|
+
handle.classList.add('is-dragging');
|
|
930
|
+
document.addEventListener('mousemove', onMove);
|
|
931
|
+
document.addEventListener('mouseup', onUp);
|
|
932
|
+
document.addEventListener('touchmove', onMove, { passive: false });
|
|
933
|
+
document.addEventListener('touchend', onUp);
|
|
934
|
+
}
|
|
935
|
+
function onMove(e) {
|
|
936
|
+
e.preventDefault();
|
|
937
|
+
var t = e.touches ? e.touches[0] : e;
|
|
938
|
+
el.style.left = (origLeft + t.clientX - startX) + 'px';
|
|
939
|
+
el.style.top = (origTop + t.clientY - startY) + 'px';
|
|
940
|
+
el.style.transform = '';
|
|
941
|
+
}
|
|
942
|
+
function onUp() {
|
|
943
|
+
handle.classList.remove('is-dragging');
|
|
944
|
+
document.removeEventListener('mousemove', onMove);
|
|
945
|
+
document.removeEventListener('mouseup', onUp);
|
|
946
|
+
document.removeEventListener('touchmove', onMove);
|
|
947
|
+
document.removeEventListener('touchend', onUp);
|
|
948
|
+
}
|
|
949
|
+
handle.addEventListener('mousedown', onDown);
|
|
950
|
+
handle.addEventListener('touchstart', onDown, { passive: false });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function makeResizable(el, handle) {
|
|
954
|
+
var startX, startY, origW, origH;
|
|
955
|
+
function onDown(e) {
|
|
956
|
+
e.preventDefault(); e.stopPropagation();
|
|
957
|
+
var t = e.touches ? e.touches[0] : e;
|
|
958
|
+
startX = t.clientX; startY = t.clientY;
|
|
959
|
+
var rect = el.getBoundingClientRect();
|
|
960
|
+
origW = rect.width; origH = rect.height;
|
|
961
|
+
document.addEventListener('mousemove', onMove);
|
|
962
|
+
document.addEventListener('mouseup', onUp);
|
|
963
|
+
document.addEventListener('touchmove', onMove, { passive: false });
|
|
964
|
+
document.addEventListener('touchend', onUp);
|
|
965
|
+
}
|
|
966
|
+
function onMove(e) {
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
var t = e.touches ? e.touches[0] : e;
|
|
969
|
+
el.style.width = Math.max(240, origW + t.clientX - startX) + 'px';
|
|
970
|
+
el.style.height = Math.max(80, origH + t.clientY - startY) + 'px';
|
|
971
|
+
el.style.maxWidth = 'none';
|
|
972
|
+
el.style.maxHeight = 'none';
|
|
973
|
+
}
|
|
974
|
+
function onUp() {
|
|
975
|
+
document.removeEventListener('mousemove', onMove);
|
|
976
|
+
document.removeEventListener('mouseup', onUp);
|
|
977
|
+
document.removeEventListener('touchmove', onMove);
|
|
978
|
+
document.removeEventListener('touchend', onUp);
|
|
979
|
+
}
|
|
980
|
+
handle.addEventListener('mousedown', onDown);
|
|
981
|
+
handle.addEventListener('touchstart', onDown, { passive: false });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ── Helpers ──
|
|
985
|
+
|
|
986
|
+
function findPopup(id) {
|
|
987
|
+
if (activeTabId) {
|
|
988
|
+
return document.querySelector('[data-tab-id="' + activeTabId + '"][data-popup-id="' + id + '"]');
|
|
989
|
+
}
|
|
990
|
+
return document.querySelector('[data-popup-id="' + id + '"]');
|
|
991
|
+
}
|
|
992
|
+
// Find any popup whose ID equals baseId OR starts with baseId + ':dup:'
|
|
993
|
+
function findPopupOrDup(baseId) {
|
|
994
|
+
var exact = findPopup(baseId);
|
|
995
|
+
if (exact) return exact;
|
|
996
|
+
var tabSelector = activeTabId ? '[data-tab-id="' + activeTabId + '"]' : '';
|
|
997
|
+
var all = document.querySelectorAll(tabSelector + '[data-popup-id]');
|
|
998
|
+
var prefix = baseId + ':dup:';
|
|
999
|
+
for (var i = 0; i < all.length; i++) {
|
|
1000
|
+
if (all[i].getAttribute('data-popup-id').indexOf(prefix) === 0) return all[i];
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
function bringToFront(el) { el.style.zIndex = ++zCounter; }
|
|
1005
|
+
function escHtml(s) {
|
|
1006
|
+
var d = document.createElement('div');
|
|
1007
|
+
d.textContent = s;
|
|
1008
|
+
return d.innerHTML;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function showAdminRequired(data) {
|
|
1012
|
+
var msg = escHtml(data.error || 'Admin login required to save changes.');
|
|
1013
|
+
var html = '<p style="margin:0 0 1em;font-family:sans-serif;font-size:0.85rem">' + msg + '</p>';
|
|
1014
|
+
if (data.client_ip) {
|
|
1015
|
+
html += '<p style="margin:0 0 1em;font-family:sans-serif;font-size:0.85rem">Your IP: <code>' +
|
|
1016
|
+
escHtml(data.client_ip) + '</code></p>';
|
|
1017
|
+
}
|
|
1018
|
+
if (data.admin_url) {
|
|
1019
|
+
html += '<p style="margin:0;font-family:sans-serif;font-size:0.85rem">' +
|
|
1020
|
+
'See: <a href="' + escHtml(data.admin_url) + '" target="_blank" style="color:#4a90d9">' +
|
|
1021
|
+
escHtml(data.admin_url) + '</a></p>';
|
|
1022
|
+
}
|
|
1023
|
+
createPopup({ title: 'Admin login required', html: html, width: 420 });
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function toYaml(obj, indent) {
|
|
1027
|
+
indent = indent || 0;
|
|
1028
|
+
var pad = ' '.repeat(indent);
|
|
1029
|
+
if (obj === null || obj === undefined) return 'null';
|
|
1030
|
+
if (typeof obj === 'boolean') return obj ? 'true' : 'false';
|
|
1031
|
+
if (typeof obj === 'number') return String(obj);
|
|
1032
|
+
if (typeof obj === 'string') {
|
|
1033
|
+
if (/[:\{\}\[\],&*#?|<>=!%@`\n]/.test(obj) || obj === '' || obj !== obj.trim()) return "'" + obj.replace(/'/g, "''") + "'";
|
|
1034
|
+
return obj;
|
|
1035
|
+
}
|
|
1036
|
+
if (Array.isArray(obj)) {
|
|
1037
|
+
if (obj.length === 0) return '[]';
|
|
1038
|
+
return obj.map(function(item) {
|
|
1039
|
+
if (typeof item === 'object' && item !== null) {
|
|
1040
|
+
var inner = toYaml(item, indent + 1);
|
|
1041
|
+
var first = inner.replace(/^\s+/, '');
|
|
1042
|
+
var rest = inner.indexOf('\n') >= 0 ? '\n' + inner.split('\n').slice(1).join('\n') : '';
|
|
1043
|
+
return pad + ' - ' + first + rest;
|
|
1044
|
+
}
|
|
1045
|
+
return pad + ' - ' + toYaml(item);
|
|
1046
|
+
}).join('\n');
|
|
1047
|
+
}
|
|
1048
|
+
var keys = Object.keys(obj);
|
|
1049
|
+
if (keys.length === 0) return '{}';
|
|
1050
|
+
return keys.map(function(k) {
|
|
1051
|
+
var v = obj[k];
|
|
1052
|
+
if (typeof v === 'object' && v !== null && (Array.isArray(v) ? v.length > 0 : Object.keys(v).length > 0)) {
|
|
1053
|
+
return pad + k + ':\n' + toYaml(v, indent + 1);
|
|
1054
|
+
}
|
|
1055
|
+
return pad + k + ': ' + toYaml(v);
|
|
1056
|
+
}).join('\n');
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function closeViewMenus() {
|
|
1060
|
+
document.querySelectorAll('.br-popup.view-menu').forEach(function(el) { el.remove(); });
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// ── Duplicate popup support ──
|
|
1064
|
+
|
|
1065
|
+
var dupCounter = 0;
|
|
1066
|
+
|
|
1067
|
+
function hasPopupWithPrefix(prefix) {
|
|
1068
|
+
var tabSelector = activeTabId ? '[data-tab-id="' + activeTabId + '"]' : '';
|
|
1069
|
+
var popups = document.querySelectorAll('.br-popup' + tabSelector + '[data-popup-id]');
|
|
1070
|
+
for (var i = 0; i < popups.length; i++) {
|
|
1071
|
+
var id = popups[i].getAttribute('data-popup-id');
|
|
1072
|
+
if (id && id.indexOf(prefix) === 0) return true;
|
|
1073
|
+
}
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function updateDupIndicators() {
|
|
1078
|
+
document.querySelectorAll('.br-dup-btn').forEach(function(btn) {
|
|
1079
|
+
var parentPopup = btn.closest('.br-popup');
|
|
1080
|
+
if (parentPopup && parentPopup.style.display === 'none') return;
|
|
1081
|
+
var path = btn.getAttribute('data-dup-path');
|
|
1082
|
+
var dbKey = btn.getAttribute('data-dup-db');
|
|
1083
|
+
var tableKey = btn.getAttribute('data-dup-table');
|
|
1084
|
+
var tableDbKey = btn.getAttribute('data-dup-table-db');
|
|
1085
|
+
var show = false;
|
|
1086
|
+
if (path !== null) show = hasPopupWithPrefix('content:' + path);
|
|
1087
|
+
else if (tableKey !== null) show = hasPopupWithPrefix('csv:' + tableDbKey + ':' + tableKey + ':');
|
|
1088
|
+
else if (dbKey !== null) show = hasPopupWithPrefix('db:' + dbKey);
|
|
1089
|
+
btn.style.visibility = show ? 'visible' : 'hidden';
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
var dupObserver = new MutationObserver(function(mutations) {
|
|
1094
|
+
var needed = false;
|
|
1095
|
+
mutations.forEach(function(m) {
|
|
1096
|
+
m.addedNodes.forEach(function(n) { if (n.classList && n.classList.contains('br-popup')) needed = true; });
|
|
1097
|
+
m.removedNodes.forEach(function(n) { if (n.classList && n.classList.contains('br-popup')) needed = true; });
|
|
1098
|
+
});
|
|
1099
|
+
if (needed) updateDupIndicators();
|
|
1100
|
+
});
|
|
1101
|
+
dupObserver.observe(document.body, { childList: true });
|
|
1102
|
+
|
|
1103
|
+
var cascadeOffset = 0;
|
|
1104
|
+
function nextPosition() {
|
|
1105
|
+
cascadeOffset += 30;
|
|
1106
|
+
if (cascadeOffset > 300) cascadeOffset = 30;
|
|
1107
|
+
return { x: 80 + cascadeOffset, y: TAB_BAR_HEIGHT + 30 + cascadeOffset };
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ── Layout save/restore ──
|
|
1111
|
+
|
|
1112
|
+
function serializePopup(el) {
|
|
1113
|
+
if (!el._getLayoutState) return null;
|
|
1114
|
+
var rect = el.getBoundingClientRect();
|
|
1115
|
+
return {
|
|
1116
|
+
id: el.getAttribute('data-popup-id'),
|
|
1117
|
+
x: parseInt(el.style.left) || Math.round(rect.left),
|
|
1118
|
+
y: parseInt(el.style.top) || Math.round(rect.top),
|
|
1119
|
+
width: Math.round(rect.width),
|
|
1120
|
+
height: Math.round(rect.height),
|
|
1121
|
+
zIndex: parseInt(el.style.zIndex) || 0,
|
|
1122
|
+
state: el._getLayoutState()
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function serializeAllPopups() {
|
|
1127
|
+
var selector = '.br-popup:not(.view-menu)';
|
|
1128
|
+
if (activeTabId) selector += '[data-tab-id="' + activeTabId + '"]';
|
|
1129
|
+
var popups = Array.from(document.querySelectorAll(selector));
|
|
1130
|
+
return popups.map(serializePopup).filter(Boolean)
|
|
1131
|
+
.sort(function(a, b) { return a.zIndex - b.zIndex; });
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
var LAYOUT_KEY = 'markdownr:layouts';
|
|
1135
|
+
var currentLayoutName = null;
|
|
1136
|
+
|
|
1137
|
+
function getLayouts() {
|
|
1138
|
+
try { return JSON.parse(localStorage.getItem(LAYOUT_KEY)) || {}; }
|
|
1139
|
+
catch(e) { return {}; }
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function saveLayout(name) {
|
|
1143
|
+
var layouts = getLayouts();
|
|
1144
|
+
layouts[name] = {
|
|
1145
|
+
savedAt: new Date().toISOString(),
|
|
1146
|
+
startMode: startMode,
|
|
1147
|
+
popups: serializeAllPopups()
|
|
1148
|
+
};
|
|
1149
|
+
localStorage.setItem(LAYOUT_KEY, JSON.stringify(layouts));
|
|
1150
|
+
currentLayoutName = name;
|
|
1151
|
+
if (activeTabId) renameTab(activeTabId, name);
|
|
1152
|
+
return layouts[name];
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function deleteLayout(name) {
|
|
1156
|
+
var layouts = getLayouts();
|
|
1157
|
+
delete layouts[name];
|
|
1158
|
+
localStorage.setItem(LAYOUT_KEY, JSON.stringify(layouts));
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function listLayouts() {
|
|
1162
|
+
var layouts = getLayouts();
|
|
1163
|
+
var names = Object.keys(layouts);
|
|
1164
|
+
if (names.length === 0) return [];
|
|
1165
|
+
return names.map(function(n) {
|
|
1166
|
+
return { name: n, savedAt: layouts[n].savedAt, popups: layouts[n].popups.length };
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function applyPopupGeometry(el, saved) {
|
|
1171
|
+
if (saved.x !== undefined) el.style.left = saved.x + 'px';
|
|
1172
|
+
if (saved.y !== undefined) el.style.top = saved.y + 'px';
|
|
1173
|
+
if (saved.width) el.style.width = saved.width + 'px';
|
|
1174
|
+
if (saved.height) el.style.height = saved.height + 'px';
|
|
1175
|
+
el.style.transform = '';
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function restoreLayout(name) {
|
|
1179
|
+
var layouts = getLayouts();
|
|
1180
|
+
var layout = layouts[name];
|
|
1181
|
+
if (!layout) return false;
|
|
1182
|
+
|
|
1183
|
+
currentLayoutName = name;
|
|
1184
|
+
if (activeTabId) renameTab(activeTabId, name);
|
|
1185
|
+
|
|
1186
|
+
// Close popups belonging to the active tab
|
|
1187
|
+
var selector = activeTabId ? '.br-popup[data-tab-id="' + activeTabId + '"]' : '.br-popup';
|
|
1188
|
+
document.querySelectorAll(selector).forEach(function(el) { el.remove(); });
|
|
1189
|
+
|
|
1190
|
+
// Restore each popup in z-index order
|
|
1191
|
+
layout.popups.forEach(function(saved) {
|
|
1192
|
+
var s = saved.state;
|
|
1193
|
+
var el;
|
|
1194
|
+
switch (s.type) {
|
|
1195
|
+
case 'db-list':
|
|
1196
|
+
showDatabaseList(csvDatabases, { noClose: true });
|
|
1197
|
+
el = findPopup('db-list');
|
|
1198
|
+
if (el) applyPopupGeometry(el, saved);
|
|
1199
|
+
break;
|
|
1200
|
+
case 'db':
|
|
1201
|
+
var db = csvDatabases.find(function(d) { return d.key === s.dbKey; });
|
|
1202
|
+
if (db) {
|
|
1203
|
+
showSingleDatabase(db, { x: saved.x, y: saved.y, width: saved.width, height: saved.height });
|
|
1204
|
+
}
|
|
1205
|
+
break;
|
|
1206
|
+
case 'csv-table':
|
|
1207
|
+
var tableLabel = s.tableLabel || s.tableKey;
|
|
1208
|
+
openCsvTablePopup(s.dbKey, s.tableKey, tableLabel, s.viewKey, s.tableColor, {
|
|
1209
|
+
searchQuery: s.searchQuery,
|
|
1210
|
+
colFilterValues: s.colFilterValues,
|
|
1211
|
+
colFiltersVisible: s.colFiltersVisible,
|
|
1212
|
+
showRowNumbers: s.showRowNumbers,
|
|
1213
|
+
colWidths: s.colWidths,
|
|
1214
|
+
x: saved.x, y: saved.y, width: saved.width, height: saved.height
|
|
1215
|
+
});
|
|
1216
|
+
break;
|
|
1217
|
+
case 'db-search':
|
|
1218
|
+
var searchDb = csvDatabases.find(function(d) { return d.key === s.dbKey; });
|
|
1219
|
+
if (searchDb) {
|
|
1220
|
+
showDatabaseSearch(searchDb, s.query, { x: saved.x, y: saved.y, width: saved.width, height: saved.height });
|
|
1221
|
+
}
|
|
1222
|
+
break;
|
|
1223
|
+
case 'schema':
|
|
1224
|
+
showTableSchema(s.dbKey, s.tableKey, s.tableColor, saved.x, saved.y, saved.width, saved.height);
|
|
1225
|
+
break;
|
|
1226
|
+
case 'content':
|
|
1227
|
+
loadContent(s.path, { pos: { x: saved.x, y: saved.y }, width: saved.width, height: saved.height });
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
return true;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
window.popupLayouts = {
|
|
1235
|
+
save: saveLayout,
|
|
1236
|
+
restore: restoreLayout,
|
|
1237
|
+
list: listLayouts,
|
|
1238
|
+
delete: deleteLayout
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
// ── Universal content loader ──
|
|
1242
|
+
|
|
1243
|
+
function loadContent(path, opts) {
|
|
1244
|
+
opts = opts || {};
|
|
1245
|
+
var baseId = 'content:' + path;
|
|
1246
|
+
var popupId = baseId;
|
|
1247
|
+
if (opts.forceNew) popupId += ':dup:' + (++dupCounter);
|
|
1248
|
+
var existing = opts.forceNew ? findPopup(popupId) : findPopupOrDup(baseId);
|
|
1249
|
+
if (existing) { bringToFront(existing); return; }
|
|
1250
|
+
|
|
1251
|
+
var pos = opts.pos || nextPosition();
|
|
1252
|
+
var popup = createPopup({
|
|
1253
|
+
id: popupId,
|
|
1254
|
+
title: opts.title || 'Loading\u2026',
|
|
1255
|
+
pinned: true,
|
|
1256
|
+
noClose: !!opts.noClose,
|
|
1257
|
+
x: pos.x, y: pos.y,
|
|
1258
|
+
width: opts.width, height: opts.height
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
popup._getLayoutState = function() {
|
|
1262
|
+
return { type: 'content', path: path };
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
var url = '/browser/api/render/' + encodeURI(path);
|
|
1266
|
+
if (opts.queryParams) url += '?' + opts.queryParams;
|
|
1267
|
+
|
|
1268
|
+
fetch(url)
|
|
1269
|
+
.then(function(r) { return r.json(); })
|
|
1270
|
+
.then(function(data) {
|
|
1271
|
+
popup.querySelector('.br-popup-title').textContent = data.title;
|
|
1272
|
+
if (data.color) applyPopupColor(popup, data.color);
|
|
1273
|
+
if (data.type === 'directory' || data.type === 'markdown' || data.type === 'code') {
|
|
1274
|
+
popup.setAttribute('data-browse-href', '/browse/' + encodeURI(path));
|
|
1275
|
+
} else if (data.type === 'download' && data.href) {
|
|
1276
|
+
popup.setAttribute('data-browse-href', data.href);
|
|
1277
|
+
} else if (data.type === 'external' && data.href) {
|
|
1278
|
+
popup.setAttribute('data-browse-href', data.href);
|
|
1279
|
+
}
|
|
1280
|
+
dispatchRenderer(popup, data);
|
|
1281
|
+
})
|
|
1282
|
+
.catch(function() {
|
|
1283
|
+
popup.querySelector('.br-popup-body').innerHTML = '<div class="br-empty">Error loading content</div>';
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function dispatchRenderer(popup, data) {
|
|
1288
|
+
switch (data.type) {
|
|
1289
|
+
case 'directory': renderDirectory(popup, data); break;
|
|
1290
|
+
case 'markdown': renderMarkdown(popup, data); break;
|
|
1291
|
+
case 'code': renderCode(popup, data); break;
|
|
1292
|
+
case 'csv_database': renderCsvDatabase(popup, data); break;
|
|
1293
|
+
case 'csv_table':
|
|
1294
|
+
if (data.db_key && data.table_key) {
|
|
1295
|
+
var defaultView = getDefaultViewKey(data.db_key, data.table_key, data.views);
|
|
1296
|
+
if (defaultView !== data.view_key) {
|
|
1297
|
+
// Saved default differs — replace content popup with a proper csv-table popup
|
|
1298
|
+
var rect = popup.getBoundingClientRect();
|
|
1299
|
+
popup.remove();
|
|
1300
|
+
var viewLabel = data.table_key;
|
|
1301
|
+
if (data.title) viewLabel = data.title.split(' \u2014 ')[0] || viewLabel;
|
|
1302
|
+
openCsvTablePopup(data.db_key, data.table_key, viewLabel, defaultView, data.color, {
|
|
1303
|
+
x: Math.round(rect.left), y: Math.round(rect.top),
|
|
1304
|
+
width: Math.round(rect.width), height: Math.round(rect.height)
|
|
1305
|
+
});
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
renderCsvTable(popup, data);
|
|
1310
|
+
break;
|
|
1311
|
+
case 'download': renderDownload(popup, data); break;
|
|
1312
|
+
case 'external': renderExternal(popup, data); break;
|
|
1313
|
+
default: popup.querySelector('.br-popup-body').innerHTML = '<div class="br-empty">Unknown content type</div>';
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ── Directory renderer ──
|
|
1318
|
+
|
|
1319
|
+
function renderDirectory(popup, data) {
|
|
1320
|
+
var body = popup.querySelector('.br-popup-body');
|
|
1321
|
+
body.innerHTML = '';
|
|
1322
|
+
|
|
1323
|
+
if (!data.items || data.items.length === 0) {
|
|
1324
|
+
body.innerHTML = '<div class="br-empty">Empty directory</div>';
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
var items = data.items;
|
|
1329
|
+
var sortKey = 'mtime';
|
|
1330
|
+
var sortDir = 'desc';
|
|
1331
|
+
|
|
1332
|
+
var sortBar = document.createElement('div');
|
|
1333
|
+
sortBar.className = 'br-sort-bar';
|
|
1334
|
+
body.appendChild(sortBar);
|
|
1335
|
+
|
|
1336
|
+
var itemsDiv = document.createElement('div');
|
|
1337
|
+
itemsDiv.style.overflow = 'auto';
|
|
1338
|
+
itemsDiv.style.flex = '1';
|
|
1339
|
+
itemsDiv.style.minHeight = '0';
|
|
1340
|
+
body.style.display = 'flex';
|
|
1341
|
+
body.style.flexDirection = 'column';
|
|
1342
|
+
body.appendChild(itemsDiv);
|
|
1343
|
+
|
|
1344
|
+
var sortKeys = [
|
|
1345
|
+
{ key: 'name', label: 'Name', defaultDir: 'asc' },
|
|
1346
|
+
{ key: 'mtime', label: 'Modified', defaultDir: 'desc' },
|
|
1347
|
+
{ key: 'ctime', label: 'Created', defaultDir: 'desc' },
|
|
1348
|
+
{ key: 'size', label: 'Size', defaultDir: 'desc' }
|
|
1349
|
+
];
|
|
1350
|
+
|
|
1351
|
+
function render() {
|
|
1352
|
+
var dirs = items.filter(function(i) { return i.is_dir; });
|
|
1353
|
+
var files = items.filter(function(i) { return !i.is_dir; });
|
|
1354
|
+
|
|
1355
|
+
var sortFn = function(a, b) {
|
|
1356
|
+
var va, vb, cmp;
|
|
1357
|
+
if (sortKey === 'name') {
|
|
1358
|
+
va = a.name.toLowerCase(); vb = b.name.toLowerCase();
|
|
1359
|
+
cmp = va < vb ? -1 : va > vb ? 1 : 0;
|
|
1360
|
+
} else if (sortKey === 'size') {
|
|
1361
|
+
va = a.size_bytes === undefined ? -1 : a.size_bytes;
|
|
1362
|
+
vb = b.size_bytes === undefined ? -1 : b.size_bytes;
|
|
1363
|
+
cmp = va - vb;
|
|
1364
|
+
} else {
|
|
1365
|
+
va = a[sortKey + '_ts'] || 0; vb = b[sortKey + '_ts'] || 0;
|
|
1366
|
+
cmp = va - vb;
|
|
1367
|
+
}
|
|
1368
|
+
return sortDir === 'desc' ? -cmp : cmp;
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
dirs.sort(sortFn); files.sort(sortFn);
|
|
1372
|
+
var sorted = dirs.concat(files);
|
|
1373
|
+
|
|
1374
|
+
var barHtml = '<span>Sort:</span>';
|
|
1375
|
+
sortKeys.forEach(function(sk) {
|
|
1376
|
+
var active = sortKey === sk.key;
|
|
1377
|
+
var arrow = '';
|
|
1378
|
+
if (active) arrow = '<span class="br-sort-arrow">' + (sortDir === 'asc' ? '\u25B2' : '\u25BC') + '</span>';
|
|
1379
|
+
barHtml += '<button class="br-sort-btn' + (active ? ' active' : '') + '" data-sort="' + sk.key + '" data-default-dir="' + sk.defaultDir + '">' + sk.label + arrow + '</button>';
|
|
1380
|
+
});
|
|
1381
|
+
barHtml += '<span class="br-dir-count">' + dirs.length + ' folders, ' + files.length + ' files</span>';
|
|
1382
|
+
sortBar.innerHTML = barHtml;
|
|
1383
|
+
|
|
1384
|
+
var html = '';
|
|
1385
|
+
sorted.forEach(function(item) {
|
|
1386
|
+
html += '<div class="br-item" data-path="' + escHtml(item.path) + '" data-is-dir="' + item.is_dir + '">';
|
|
1387
|
+
html += '<span class="br-dup-btn" data-dup-path="' + escHtml(item.path) + '">+</span>';
|
|
1388
|
+
html += '<span class="br-item-icon">' + item.icon + '</span>';
|
|
1389
|
+
html += '<span class="br-item-name">' + escHtml(item.name) + (item.is_dir ? '/' : '') + '</span>';
|
|
1390
|
+
if (!item.is_dir) {
|
|
1391
|
+
html += '<span class="br-item-meta">';
|
|
1392
|
+
if (item.size) html += '<span>' + escHtml(item.size) + '</span>';
|
|
1393
|
+
if (item.mtime) html += '<span>' + escHtml(item.mtime) + '</span>';
|
|
1394
|
+
html += '</span>';
|
|
1395
|
+
}
|
|
1396
|
+
html += '</div>';
|
|
1397
|
+
});
|
|
1398
|
+
itemsDiv.innerHTML = html;
|
|
1399
|
+
updateDupIndicators();
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
sortBar.addEventListener('click', function(e) {
|
|
1403
|
+
var btn = e.target.closest('.br-sort-btn');
|
|
1404
|
+
if (!btn) return;
|
|
1405
|
+
var key = btn.getAttribute('data-sort');
|
|
1406
|
+
var defaultDir = btn.getAttribute('data-default-dir');
|
|
1407
|
+
if (key === sortKey) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; }
|
|
1408
|
+
else { sortKey = key; sortDir = defaultDir; }
|
|
1409
|
+
render();
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
itemsDiv.addEventListener('click', function(e) {
|
|
1413
|
+
var dupBtn = e.target.closest('.br-dup-btn');
|
|
1414
|
+
if (dupBtn) {
|
|
1415
|
+
var dupPath = dupBtn.getAttribute('data-dup-path');
|
|
1416
|
+
loadContent(dupPath, { title: dupPath.split('/').pop(), forceNew: true });
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
var item = e.target.closest('.br-item');
|
|
1420
|
+
if (!item) return;
|
|
1421
|
+
var path = item.getAttribute('data-path');
|
|
1422
|
+
loadContent(path, { title: path.split('/').pop() });
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
render();
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// ── Markdown renderer ──
|
|
1429
|
+
|
|
1430
|
+
function renderMarkdown(popup, data) {
|
|
1431
|
+
var body = popup.querySelector('.br-popup-body');
|
|
1432
|
+
body.innerHTML = '';
|
|
1433
|
+
body.style.padding = '0';
|
|
1434
|
+
popup.classList.add('wide');
|
|
1435
|
+
|
|
1436
|
+
if (data.frontmatter_html) {
|
|
1437
|
+
var fmWrap = document.createElement('div');
|
|
1438
|
+
fmWrap.className = 'br-frontmatter';
|
|
1439
|
+
fmWrap.innerHTML = data.frontmatter_html;
|
|
1440
|
+
body.appendChild(fmWrap);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
var content = document.createElement('div');
|
|
1444
|
+
content.className = 'br-md-content';
|
|
1445
|
+
content.innerHTML = data.html;
|
|
1446
|
+
body.appendChild(content);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// ── Code renderer ──
|
|
1450
|
+
|
|
1451
|
+
function renderCode(popup, data) {
|
|
1452
|
+
var body = popup.querySelector('.br-popup-body');
|
|
1453
|
+
body.innerHTML = '';
|
|
1454
|
+
body.style.display = 'flex';
|
|
1455
|
+
body.style.flexDirection = 'column';
|
|
1456
|
+
body.style.padding = '0';
|
|
1457
|
+
popup.classList.add('wide');
|
|
1458
|
+
|
|
1459
|
+
var toolbar = document.createElement('div');
|
|
1460
|
+
toolbar.className = 'br-code-toolbar';
|
|
1461
|
+
toolbar.textContent = data.language;
|
|
1462
|
+
body.appendChild(toolbar);
|
|
1463
|
+
|
|
1464
|
+
var code = document.createElement('pre');
|
|
1465
|
+
code.className = 'br-code-view';
|
|
1466
|
+
code.innerHTML = data.html;
|
|
1467
|
+
body.appendChild(code);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// ── Download renderer ──
|
|
1471
|
+
|
|
1472
|
+
function renderDownload(popup, data) {
|
|
1473
|
+
var body = popup.querySelector('.br-popup-body');
|
|
1474
|
+
body.innerHTML = '<div class="br-link-body">' +
|
|
1475
|
+
'<a href="' + escHtml(data.href) + '" download>Download ' + escHtml(data.title) + '</a>' +
|
|
1476
|
+
(data.size ? '<span class="br-link-size">' + escHtml(data.size) + '</span>' : '') +
|
|
1477
|
+
'</div>';
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// ── External (HTML) renderer ──
|
|
1481
|
+
|
|
1482
|
+
function renderExternal(popup, data) {
|
|
1483
|
+
var body = popup.querySelector('.br-popup-body');
|
|
1484
|
+
body.innerHTML = '<div class="br-link-body">' +
|
|
1485
|
+
'<a href="' + escHtml(data.href) + '" target="_blank">Open ' + escHtml(data.title) + '</a>' +
|
|
1486
|
+
'</div>';
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// ── CSV Database renderer ──
|
|
1490
|
+
|
|
1491
|
+
function renderCsvDatabase(popup, data) {
|
|
1492
|
+
var body = popup.querySelector('.br-popup-body');
|
|
1493
|
+
body.innerHTML = '';
|
|
1494
|
+
body.style.padding = '0.4rem 0.5rem';
|
|
1495
|
+
|
|
1496
|
+
var html = '';
|
|
1497
|
+
data.tables.forEach(function(t) {
|
|
1498
|
+
var dupIcon = '<span class="br-dup-btn" data-dup-table="' + escHtml(t.key) + '" data-dup-table-db="' + escHtml(data.db_key) + '">+</span>';
|
|
1499
|
+
html += '<button class="br-list-item" data-table="' + t.key + '">' + dupIcon + escHtml(t.title) + '</button>';
|
|
1500
|
+
});
|
|
1501
|
+
body.innerHTML = html;
|
|
1502
|
+
|
|
1503
|
+
body.addEventListener('click', function(e) {
|
|
1504
|
+
var dupBtn = e.target.closest('.br-dup-btn');
|
|
1505
|
+
if (dupBtn) {
|
|
1506
|
+
e.stopPropagation();
|
|
1507
|
+
var dupTableKey = dupBtn.getAttribute('data-dup-table');
|
|
1508
|
+
var dupTable = data.tables.find(function(t) { return t.key === dupTableKey; });
|
|
1509
|
+
if (dupTable) showCsvViewMenu(data.db_key, dupTable, e.clientX, e.clientY, true);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
var btn = e.target.closest('[data-table]');
|
|
1513
|
+
if (!btn) return;
|
|
1514
|
+
var tableKey = btn.getAttribute('data-table');
|
|
1515
|
+
var table = data.tables.find(function(t) { return t.key === tableKey; });
|
|
1516
|
+
if (table) showCsvViewMenu(data.db_key, table, e.clientX, e.clientY);
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function getDefaultViewKey(dbKey, tableKey, views) {
|
|
1521
|
+
if (!views || views.length === 0) return 'all';
|
|
1522
|
+
try {
|
|
1523
|
+
var saved = localStorage.getItem('markdownr:defaultView:' + dbKey + ':' + tableKey);
|
|
1524
|
+
if (saved) {
|
|
1525
|
+
var obj = JSON.parse(saved);
|
|
1526
|
+
var savedKey = obj['view-name'];
|
|
1527
|
+
if (savedKey && views.find(function(v) { return v.key === savedKey; })) return savedKey;
|
|
1528
|
+
}
|
|
1529
|
+
} catch(e) {}
|
|
1530
|
+
return views[0].key;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function getSavedDefaultView(dbKey, tableKey) {
|
|
1534
|
+
try {
|
|
1535
|
+
var saved = localStorage.getItem('markdownr:defaultView:' + dbKey + ':' + tableKey);
|
|
1536
|
+
if (saved) return JSON.parse(saved);
|
|
1537
|
+
} catch(e) {}
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function saveDefaultViewKey(dbKey, tableKey, viewKey, extra) {
|
|
1542
|
+
try {
|
|
1543
|
+
var obj = { 'view-name': viewKey };
|
|
1544
|
+
if (extra) {
|
|
1545
|
+
Object.keys(extra).forEach(function(k) { obj[k] = extra[k]; });
|
|
1546
|
+
}
|
|
1547
|
+
localStorage.setItem('markdownr:defaultView:' + dbKey + ':' + tableKey, JSON.stringify(obj));
|
|
1548
|
+
} catch(e) {}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function openDefaultView(dbKey, table, opts) {
|
|
1552
|
+
opts = opts || {};
|
|
1553
|
+
// If a popup already exists for this table (any view), just raise it
|
|
1554
|
+
var prefix = 'csv:' + dbKey + ':' + table.key + ':';
|
|
1555
|
+
var tabSelector = activeTabId ? '[data-tab-id="' + activeTabId + '"]' : '';
|
|
1556
|
+
var existing = document.querySelector(tabSelector + '[data-popup-id^="' + prefix + '"]');
|
|
1557
|
+
if (existing) { bringToFront(existing); return; }
|
|
1558
|
+
var viewKey = getDefaultViewKey(dbKey, table.key, table.views);
|
|
1559
|
+
openCsvTablePopup(dbKey, table.key, table.title, viewKey, table.color, opts);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function showCsvViewMenu(dbKey, table, x, y, forceNew) {
|
|
1563
|
+
closeViewMenus();
|
|
1564
|
+
// If not duplicating and a popup already exists for this table (any view), just raise it
|
|
1565
|
+
if (!forceNew) {
|
|
1566
|
+
var prefix = 'csv:' + dbKey + ':' + table.key + ':';
|
|
1567
|
+
var tabSelector = activeTabId ? '[data-tab-id="' + activeTabId + '"]' : '';
|
|
1568
|
+
var existing = document.querySelector(tabSelector + '[data-popup-id^="' + prefix + '"]');
|
|
1569
|
+
if (existing) { bringToFront(existing); return; }
|
|
1570
|
+
}
|
|
1571
|
+
if (table.views.length === 1) {
|
|
1572
|
+
openCsvTablePopup(dbKey, table.key, table.title, table.views[0].key, table.color, forceNew ? { forceNew: true } : undefined);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
var html = '';
|
|
1576
|
+
table.views.forEach(function(v) {
|
|
1577
|
+
html += '<button class="br-list-item" data-view="' + v.key + '">' + escHtml(v.title) + '</button>';
|
|
1578
|
+
});
|
|
1579
|
+
var popup = createPopup({ title: table.title, html: html, viewMenu: true, x: x, y: y });
|
|
1580
|
+
popup.querySelector('.br-popup-body').innerHTML = html;
|
|
1581
|
+
popup.querySelector('.br-popup-body').addEventListener('click', function(e) {
|
|
1582
|
+
var btn = e.target.closest('[data-view]');
|
|
1583
|
+
if (!btn) return;
|
|
1584
|
+
popup.remove();
|
|
1585
|
+
openCsvTablePopup(dbKey, table.key, table.title, btn.getAttribute('data-view'), table.color, forceNew ? { forceNew: true } : undefined);
|
|
1586
|
+
});
|
|
1587
|
+
setTimeout(function() {
|
|
1588
|
+
document.addEventListener('mousedown', function handler(e) {
|
|
1589
|
+
if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('mousedown', handler); }
|
|
1590
|
+
});
|
|
1591
|
+
}, 0);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function openCsvTablePopup(dbKey, tableKey, tableLabel, viewKey, tableColor, openOpts) {
|
|
1595
|
+
openOpts = openOpts || {};
|
|
1596
|
+
var baseId = 'csv:' + dbKey + ':' + tableKey + ':' + viewKey;
|
|
1597
|
+
if (openOpts.searchQuery) baseId += ':q:' + openOpts.searchQuery;
|
|
1598
|
+
var popupId = baseId;
|
|
1599
|
+
if (openOpts.forceNew) popupId += ':dup:' + (++dupCounter);
|
|
1600
|
+
var existing = openOpts.forceNew ? findPopup(popupId) : findPopupOrDup(baseId);
|
|
1601
|
+
if (existing) { bringToFront(existing); return; }
|
|
1602
|
+
|
|
1603
|
+
var pos = (openOpts.x !== undefined) ? { x: openOpts.x, y: openOpts.y } : nextPosition();
|
|
1604
|
+
var popup = createPopup({
|
|
1605
|
+
id: popupId,
|
|
1606
|
+
title: tableLabel + ' \u2014 loading\u2026',
|
|
1607
|
+
pinned: true, wide: true, x: pos.x, y: pos.y,
|
|
1608
|
+
width: openOpts.width, height: openOpts.height,
|
|
1609
|
+
color: tableColor
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
var url;
|
|
1613
|
+
if (dbKey === '_unmapped') {
|
|
1614
|
+
url = '/browser/api/csv/unmapped/' + tableKey;
|
|
1615
|
+
} else {
|
|
1616
|
+
url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
1617
|
+
'/tables/' + encodeURIComponent(tableKey) +
|
|
1618
|
+
'?view=' + encodeURIComponent(viewKey);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
fetch(url)
|
|
1622
|
+
.then(function(r) { return r.json(); })
|
|
1623
|
+
.then(function(data) {
|
|
1624
|
+
var viewLabel = '';
|
|
1625
|
+
data.views.forEach(function(v) { if (v.key === viewKey) viewLabel = v.title; });
|
|
1626
|
+
popup.querySelector('.br-popup-title').textContent = tableLabel + ' \u2014 ' + viewLabel;
|
|
1627
|
+
var initOpts = {};
|
|
1628
|
+
var savedObj = (dbKey && dbKey !== '_unmapped') ? getSavedDefaultView(dbKey, tableKey) : null;
|
|
1629
|
+
var savedMatches = savedObj && savedObj['view-name'] === viewKey;
|
|
1630
|
+
if (openOpts.searchQuery) initOpts.searchQuery = openOpts.searchQuery;
|
|
1631
|
+
else if (savedMatches && savedObj.searchQuery) initOpts.searchQuery = savedObj.searchQuery;
|
|
1632
|
+
if (openOpts.colFilterValues) initOpts.colFilterValues = openOpts.colFilterValues;
|
|
1633
|
+
else if (savedMatches && savedObj.colFilterValues) initOpts.colFilterValues = savedObj.colFilterValues;
|
|
1634
|
+
if (openOpts.colFiltersVisible) initOpts.colFiltersVisible = openOpts.colFiltersVisible;
|
|
1635
|
+
else if (savedMatches && savedObj.colFiltersVisible) initOpts.colFiltersVisible = savedObj.colFiltersVisible;
|
|
1636
|
+
if (openOpts.showRowNumbers) initOpts.showRowNumbers = openOpts.showRowNumbers;
|
|
1637
|
+
if (openOpts.colWidths) {
|
|
1638
|
+
initOpts.colWidths = openOpts.colWidths;
|
|
1639
|
+
} else if (savedMatches && savedObj.colWidths) {
|
|
1640
|
+
initOpts.colWidths = savedObj.colWidths;
|
|
1641
|
+
}
|
|
1642
|
+
initCsvTableView(popup, data, dbKey, initOpts);
|
|
1643
|
+
})
|
|
1644
|
+
.catch(function() {
|
|
1645
|
+
popup.querySelector('.br-popup-body').innerHTML = '<div class="br-empty">Error loading data</div>';
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ── CSV Table renderer (also used for inline csv_table type) ──
|
|
1650
|
+
|
|
1651
|
+
function renderCsvTable(popup, data) {
|
|
1652
|
+
popup.classList.add('wide');
|
|
1653
|
+
var savedObj = getSavedDefaultView(data.db_key, data.table_key);
|
|
1654
|
+
function buildInitOpts(viewKey) {
|
|
1655
|
+
var opts = {};
|
|
1656
|
+
if (!savedObj || savedObj['view-name'] !== viewKey) return opts;
|
|
1657
|
+
if (savedObj.colWidths) opts.colWidths = savedObj.colWidths;
|
|
1658
|
+
if (savedObj.searchQuery) opts.searchQuery = savedObj.searchQuery;
|
|
1659
|
+
if (savedObj.colFilterValues) opts.colFilterValues = savedObj.colFilterValues;
|
|
1660
|
+
if (savedObj.colFiltersVisible) opts.colFiltersVisible = savedObj.colFiltersVisible;
|
|
1661
|
+
return opts;
|
|
1662
|
+
}
|
|
1663
|
+
// Check if a saved default view differs from what the server returned
|
|
1664
|
+
var savedViewKey = getDefaultViewKey(data.db_key, data.table_key, data.views);
|
|
1665
|
+
if (savedViewKey !== data.view_key && data.db_key && data.table_key) {
|
|
1666
|
+
// Re-fetch with the saved default view
|
|
1667
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(data.db_key) +
|
|
1668
|
+
'/tables/' + encodeURIComponent(data.table_key) +
|
|
1669
|
+
'?view=' + encodeURIComponent(savedViewKey);
|
|
1670
|
+
fetch(url)
|
|
1671
|
+
.then(function(r) { return r.json(); })
|
|
1672
|
+
.then(function(newData) {
|
|
1673
|
+
var viewLabel = '';
|
|
1674
|
+
if (newData.views) newData.views.forEach(function(v) { if (v.key === savedViewKey) viewLabel = v.title; });
|
|
1675
|
+
if (viewLabel) popup.querySelector('.br-popup-title').textContent = newData.title;
|
|
1676
|
+
initCsvTableView(popup, newData, data.db_key, buildInitOpts(savedViewKey));
|
|
1677
|
+
})
|
|
1678
|
+
.catch(function() {
|
|
1679
|
+
// Fall back to the original data
|
|
1680
|
+
initCsvTableView(popup, data, data.db_key);
|
|
1681
|
+
});
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
var viewLabel = '';
|
|
1685
|
+
if (data.views) data.views.forEach(function(v) { if (v.key === data.view_key) viewLabel = v.title; });
|
|
1686
|
+
if (viewLabel) popup.querySelector('.br-popup-title').textContent = data.title;
|
|
1687
|
+
initCsvTableView(popup, data, data.db_key, buildInitOpts(data.view_key));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// ── Comparison filter utilities ──
|
|
1691
|
+
|
|
1692
|
+
function parseComparableValue(s) {
|
|
1693
|
+
// Try number (strip currency symbols and commas)
|
|
1694
|
+
var numStr = s.replace(/[$,]/g, '');
|
|
1695
|
+
var num = parseFloat(numStr);
|
|
1696
|
+
if (!isNaN(num) && isFinite(num) && /^-?\d*\.?\d+$/.test(numStr)) {
|
|
1697
|
+
return { kind: 'number', value: num };
|
|
1698
|
+
}
|
|
1699
|
+
// Try date (M/D/YYYY or MM/DD/YYYY)
|
|
1700
|
+
var m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
1701
|
+
if (m) {
|
|
1702
|
+
var d = new Date(parseInt(m[3]), parseInt(m[1]) - 1, parseInt(m[2]));
|
|
1703
|
+
if (!isNaN(d.getTime())) return { kind: 'date', value: d.getTime() };
|
|
1704
|
+
}
|
|
1705
|
+
// Try ISO date (YYYY-MM-DD)
|
|
1706
|
+
var iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
1707
|
+
if (iso) {
|
|
1708
|
+
var d = new Date(parseInt(iso[1]), parseInt(iso[2]) - 1, parseInt(iso[3]));
|
|
1709
|
+
if (!isNaN(d.getTime())) return { kind: 'date', value: d.getTime() };
|
|
1710
|
+
}
|
|
1711
|
+
return null;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function compareValues(cellVal, op, refVal) {
|
|
1715
|
+
if (!cellVal || cellVal.kind !== refVal.kind) return false;
|
|
1716
|
+
switch (op) {
|
|
1717
|
+
case '>': return cellVal.value > refVal.value;
|
|
1718
|
+
case '>=': return cellVal.value >= refVal.value;
|
|
1719
|
+
case '<': return cellVal.value < refVal.value;
|
|
1720
|
+
case '<=': return cellVal.value <= refVal.value;
|
|
1721
|
+
}
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function parseFilterTerm(term) {
|
|
1726
|
+
var m = term.match(/^(>=?|<=?)\s*(.+)$/);
|
|
1727
|
+
if (m) {
|
|
1728
|
+
var val = parseComparableValue(m[2]);
|
|
1729
|
+
if (val) return { type: 'compare', op: m[1], value: val };
|
|
1730
|
+
}
|
|
1731
|
+
try { return { type: 'regex', re: new RegExp(term, 'i') }; }
|
|
1732
|
+
catch(e) { return { type: 'regex', re: new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') }; }
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function parseFilterTerms(raw) {
|
|
1736
|
+
if (!raw || raw.trim() === '') return [];
|
|
1737
|
+
return raw.trim().split(/\s+/).map(parseFilterTerm);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function termMatchesCell(term, cellStr) {
|
|
1741
|
+
if (term.type === 'compare') {
|
|
1742
|
+
return compareValues(parseComparableValue(cellStr), term.op, term.value);
|
|
1743
|
+
}
|
|
1744
|
+
return term.re.test(cellStr);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function initCsvTableView(popup, data, dbKey, initOpts) {
|
|
1748
|
+
initOpts = initOpts || {};
|
|
1749
|
+
var body = popup.querySelector('.br-popup-body');
|
|
1750
|
+
body.innerHTML = '';
|
|
1751
|
+
body.style.padding = '0.5rem 0.75rem';
|
|
1752
|
+
body.style.display = 'flex';
|
|
1753
|
+
body.style.flexDirection = 'column';
|
|
1754
|
+
|
|
1755
|
+
if (!data.rows || data.rows.length === 0) {
|
|
1756
|
+
body.innerHTML = '<div class="br-empty">No data</div>';
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
var columns = data.columns;
|
|
1761
|
+
var refs = data.references || {};
|
|
1762
|
+
var reverseRefs = data.reverse_references || [];
|
|
1763
|
+
var originalRows = data.rows.slice();
|
|
1764
|
+
var displayRows = originalRows.slice();
|
|
1765
|
+
var sortCol = -1, sortDirection = 0;
|
|
1766
|
+
var searchTerms = [];
|
|
1767
|
+
var highlightMode = null; // null, 'cells', or 'rows'
|
|
1768
|
+
var highlightTerms = [];
|
|
1769
|
+
var dirtyRows = {};
|
|
1770
|
+
var selectedRowIdx = null;
|
|
1771
|
+
var editingCell = null;
|
|
1772
|
+
var validationErrors = {}; // { rowIndex: [{message, fields}] }
|
|
1773
|
+
var cellValidationErrors = {}; // { "rowIdx:colKey": "error message" }
|
|
1774
|
+
var serverCellErrorKeys = []; // keys added by server validation (for cleanup on re-validate)
|
|
1775
|
+
var requiredFields = data.required || [];
|
|
1776
|
+
|
|
1777
|
+
var colFilterValues = initOpts.colFilterValues || {};
|
|
1778
|
+
var hasInitColFilterValues = Object.keys(colFilterValues).some(function(k) { return colFilterValues[k]; });
|
|
1779
|
+
var colFiltersVisible = !!initOpts.colFiltersVisible || hasInitColFilterValues;
|
|
1780
|
+
var showRowNumbers = !!initOpts.showRowNumbers;
|
|
1781
|
+
var colWidths = initOpts.colWidths || {};
|
|
1782
|
+
var editMode = false;
|
|
1783
|
+
var isReadonly = !!data.readonly;
|
|
1784
|
+
|
|
1785
|
+
var searchWrap = document.createElement('div');
|
|
1786
|
+
searchWrap.className = 'br-search-wrap';
|
|
1787
|
+
var modeToggle = document.createElement('button');
|
|
1788
|
+
modeToggle.className = 'br-mode-toggle';
|
|
1789
|
+
modeToggle.type = 'button';
|
|
1790
|
+
modeToggle.title = 'View mode \u2014 click to switch to edit mode';
|
|
1791
|
+
modeToggle.textContent = '\ud83d\udcd6';
|
|
1792
|
+
if (isReadonly) modeToggle.style.display = 'none';
|
|
1793
|
+
searchWrap.appendChild(modeToggle);
|
|
1794
|
+
var validateBtn = document.createElement('button');
|
|
1795
|
+
validateBtn.className = 'br-validate-btn';
|
|
1796
|
+
validateBtn.type = 'button';
|
|
1797
|
+
validateBtn.title = 'Validate all rows';
|
|
1798
|
+
validateBtn.textContent = '\ud83d\udd25';
|
|
1799
|
+
if (isReadonly) validateBtn.style.display = 'none';
|
|
1800
|
+
searchWrap.appendChild(validateBtn);
|
|
1801
|
+
var rowNumToggle = document.createElement('button');
|
|
1802
|
+
rowNumToggle.className = 'br-rownum-toggle' + (showRowNumbers ? ' active' : '');
|
|
1803
|
+
rowNumToggle.type = 'button';
|
|
1804
|
+
rowNumToggle.title = 'Toggle row numbers';
|
|
1805
|
+
rowNumToggle.textContent = '#';
|
|
1806
|
+
searchWrap.appendChild(rowNumToggle);
|
|
1807
|
+
var saveDefaultBtn = document.createElement('button');
|
|
1808
|
+
saveDefaultBtn.className = 'br-save-default-btn';
|
|
1809
|
+
saveDefaultBtn.type = 'button';
|
|
1810
|
+
saveDefaultBtn.textContent = '\ud83d\udcbe';
|
|
1811
|
+
var _curViewKey = data.view || data.view_key || 'all';
|
|
1812
|
+
var _curTableKey = data.table || data.table_key;
|
|
1813
|
+
var _savedDefault = (dbKey && dbKey !== '_unmapped') ? getDefaultViewKey(dbKey, _curTableKey, data.views) : null;
|
|
1814
|
+
if (_savedDefault === _curViewKey && _savedDefault !== (data.views && data.views[0] && data.views[0].key)) {
|
|
1815
|
+
saveDefaultBtn.classList.add('active');
|
|
1816
|
+
saveDefaultBtn.title = 'This is the saved default view';
|
|
1817
|
+
} else {
|
|
1818
|
+
saveDefaultBtn.title = 'Save as default view';
|
|
1819
|
+
}
|
|
1820
|
+
if ((initOpts.colWidths && Object.keys(initOpts.colWidths).length > 0) ||
|
|
1821
|
+
initOpts.searchQuery ||
|
|
1822
|
+
(initOpts.colFilterValues && Object.keys(initOpts.colFilterValues).length > 0)) {
|
|
1823
|
+
saveDefaultBtn.classList.add('loaded');
|
|
1824
|
+
}
|
|
1825
|
+
if (dbKey && dbKey !== '_unmapped') {
|
|
1826
|
+
searchWrap.appendChild(saveDefaultBtn);
|
|
1827
|
+
}
|
|
1828
|
+
var searchInput = document.createElement('input');
|
|
1829
|
+
searchInput.className = 'br-search';
|
|
1830
|
+
searchInput.type = 'search';
|
|
1831
|
+
searchInput.placeholder = 'Filter rows\u2026 (regex, >N, >=N, <N, <=N, ! to highlight)';
|
|
1832
|
+
searchWrap.appendChild(searchInput);
|
|
1833
|
+
var colFilterToggle = document.createElement('button');
|
|
1834
|
+
colFilterToggle.className = 'br-col-filter-toggle';
|
|
1835
|
+
colFilterToggle.type = 'button';
|
|
1836
|
+
colFilterToggle.title = 'Per-column filters';
|
|
1837
|
+
colFilterToggle.textContent = colFiltersVisible ? '\u25b4' : '\u25be';
|
|
1838
|
+
searchWrap.appendChild(colFilterToggle);
|
|
1839
|
+
body.appendChild(searchWrap);
|
|
1840
|
+
|
|
1841
|
+
var tableWrap = document.createElement('div');
|
|
1842
|
+
tableWrap.className = 'br-table-wrap';
|
|
1843
|
+
body.appendChild(tableWrap);
|
|
1844
|
+
|
|
1845
|
+
function getCellValue(row, colIndex) {
|
|
1846
|
+
var val = row[colIndex + 1];
|
|
1847
|
+
var rowIdx = row[0];
|
|
1848
|
+
var colKey = columns[colIndex].key;
|
|
1849
|
+
if (dirtyRows[rowIdx]) {
|
|
1850
|
+
if (dirtyRows[rowIdx][colKey] !== undefined) val = dirtyRows[rowIdx][colKey];
|
|
1851
|
+
}
|
|
1852
|
+
var s = (val === null || val === undefined) ? '' : String(val);
|
|
1853
|
+
var refMap = refs[colKey];
|
|
1854
|
+
if (refMap && refMap[s]) s = refMap[s];
|
|
1855
|
+
return s;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function render() {
|
|
1859
|
+
var activeColFilters = [];
|
|
1860
|
+
if (colFiltersVisible) {
|
|
1861
|
+
columns.forEach(function(col, ci) {
|
|
1862
|
+
var terms = parseFilterTerms(colFilterValues[col.key]);
|
|
1863
|
+
if (terms.length > 0) activeColFilters.push({ ci: ci, terms: terms });
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// In highlight mode, show all rows (only apply col filters); otherwise filter normally
|
|
1868
|
+
displayRows = originalRows.filter(function(row) {
|
|
1869
|
+
if (!highlightMode && searchTerms.length > 0) {
|
|
1870
|
+
var globalMatch = searchTerms.every(function(term) {
|
|
1871
|
+
for (var i = 0; i < columns.length; i++) {
|
|
1872
|
+
if (termMatchesCell(term, getCellValue(row, i))) return true;
|
|
1873
|
+
}
|
|
1874
|
+
return false;
|
|
1875
|
+
});
|
|
1876
|
+
if (!globalMatch) return false;
|
|
1877
|
+
}
|
|
1878
|
+
// Per-column filters always apply
|
|
1879
|
+
for (var f = 0; f < activeColFilters.length; f++) {
|
|
1880
|
+
var af = activeColFilters[f];
|
|
1881
|
+
var cellVal = getCellValue(row, af.ci);
|
|
1882
|
+
for (var t = 0; t < af.terms.length; t++) {
|
|
1883
|
+
if (!termMatchesCell(af.terms[t], cellVal)) return false;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
return true;
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
if (sortCol >= 0 && sortDirection !== 0) {
|
|
1890
|
+
var ci = sortCol + 1;
|
|
1891
|
+
var colType = columns[sortCol].type;
|
|
1892
|
+
displayRows.sort(function(a, b) {
|
|
1893
|
+
var va = a[ci], vb = b[ci];
|
|
1894
|
+
if (va === null || va === undefined) va = '';
|
|
1895
|
+
if (vb === null || vb === undefined) vb = '';
|
|
1896
|
+
var cmp;
|
|
1897
|
+
if (colType === 'integer' || colType === 'number') {
|
|
1898
|
+
cmp = (Number(va) || 0) - (Number(vb) || 0);
|
|
1899
|
+
} else {
|
|
1900
|
+
cmp = String(va).localeCompare(String(vb));
|
|
1901
|
+
}
|
|
1902
|
+
return cmp * sortDirection;
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
renderTableHTML();
|
|
1907
|
+
applyHighlightMode();
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function applyHighlightMode() {
|
|
1911
|
+
clearMatchHighlights();
|
|
1912
|
+
if (!highlightMode || highlightTerms.length === 0) return;
|
|
1913
|
+
|
|
1914
|
+
tableWrap.querySelectorAll('tr[data-row-idx]').forEach(function(tr) {
|
|
1915
|
+
var ri = parseInt(tr.getAttribute('data-row-idx'));
|
|
1916
|
+
var row = displayRows.find(function(dr) { return dr[0] === ri; });
|
|
1917
|
+
if (!row) return;
|
|
1918
|
+
|
|
1919
|
+
if (highlightMode === 'cells') {
|
|
1920
|
+
tr.querySelectorAll('td[data-col]').forEach(function(cell) {
|
|
1921
|
+
var ci = parseInt(cell.getAttribute('data-col'));
|
|
1922
|
+
var cellStr = getCellValue(row, ci);
|
|
1923
|
+
var matches = highlightTerms.every(function(term) { return termMatchesCell(term, cellStr); });
|
|
1924
|
+
if (matches) cell.classList.add('br-cell-match');
|
|
1925
|
+
});
|
|
1926
|
+
} else if (highlightMode === 'rows') {
|
|
1927
|
+
var rowMatches = highlightTerms.every(function(term) {
|
|
1928
|
+
for (var ci = 0; ci < columns.length; ci++) {
|
|
1929
|
+
if (termMatchesCell(term, getCellValue(row, ci))) return true;
|
|
1930
|
+
}
|
|
1931
|
+
return false;
|
|
1932
|
+
});
|
|
1933
|
+
if (rowMatches) tr.classList.add('br-row-match');
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
var hasValidation = Object.keys(validationErrors).length > 0;
|
|
1939
|
+
|
|
1940
|
+
function isRightAligned(col) {
|
|
1941
|
+
if (col.type === 'integer' || col.type === 'number') return true;
|
|
1942
|
+
var c = col.constraints || {};
|
|
1943
|
+
if (c.format === 'date') return true;
|
|
1944
|
+
if (isDatePatternCol(col)) return true;
|
|
1945
|
+
return false;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function isCurrencyCol(col) {
|
|
1949
|
+
var c = col.constraints || {};
|
|
1950
|
+
return c.format === 'currency';
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function formatCurrency(val) {
|
|
1954
|
+
var n = parseFloat(val);
|
|
1955
|
+
if (isNaN(n)) return String(val);
|
|
1956
|
+
var neg = n < 0;
|
|
1957
|
+
n = Math.abs(n);
|
|
1958
|
+
var fixed = n.toFixed(2);
|
|
1959
|
+
var parts = fixed.split('.');
|
|
1960
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
1961
|
+
return (neg ? '-' : '') + '$' + parts.join('.');
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
function renderTableHTML() {
|
|
1965
|
+
var cellStyles = columns.map(function(col) {
|
|
1966
|
+
var parts = [];
|
|
1967
|
+
if (colWidths[col.key]) parts.push('white-space:normal');
|
|
1968
|
+
if (isRightAligned(col)) parts.push('text-align:right');
|
|
1969
|
+
return parts.length > 0 ? ' style="' + parts.join(';') + '"' : '';
|
|
1970
|
+
});
|
|
1971
|
+
var html = '<table class="br-data-table' + (editMode ? ' edit-mode' : '') + '"><thead><tr>';
|
|
1972
|
+
if (hasValidation) html += '<th style="width:1px;padding:0.4rem 0.2rem;"></th>';
|
|
1973
|
+
html += '<th style="width:1px;padding:0.4rem 0.3rem;"></th>';
|
|
1974
|
+
if (showRowNumbers) html += '<th style="width:1px;padding:0.4rem 0.3rem;color:#999;font-size:0.75rem;">#</th>';
|
|
1975
|
+
columns.forEach(function(col, ci) {
|
|
1976
|
+
var cls = '';
|
|
1977
|
+
var arrow = '<span class="sort-arrow">';
|
|
1978
|
+
if (ci === sortCol && sortDirection === 1) { cls = ' class="sort-asc"'; arrow += '\u25b2'; }
|
|
1979
|
+
else if (ci === sortCol && sortDirection === -1) { cls = ' class="sort-desc"'; arrow += '\u25bc'; }
|
|
1980
|
+
else { arrow += '\u25b8'; }
|
|
1981
|
+
arrow += '</span>';
|
|
1982
|
+
var wStyle = colWidths[col.key] ? ' style="width:' + colWidths[col.key] + 'px;min-width:' + colWidths[col.key] + 'px;max-width:' + colWidths[col.key] + 'px"' : '';
|
|
1983
|
+
html += '<th' + cls + ' data-sort-col="' + ci + '"' + wStyle + '>' + escHtml(col.title) + arrow + '<span class="br-col-resize" data-resize-col="' + ci + '"></span></th>';
|
|
1984
|
+
});
|
|
1985
|
+
if (reverseRefs.length > 0) {
|
|
1986
|
+
html += '<th style="white-space:nowrap;">Related</th>';
|
|
1987
|
+
}
|
|
1988
|
+
html += '</tr>';
|
|
1989
|
+
if (colFiltersVisible) {
|
|
1990
|
+
html += '<tr class="br-col-filter-row">';
|
|
1991
|
+
if (hasValidation) html += '<td></td>';
|
|
1992
|
+
html += '<td></td>';
|
|
1993
|
+
if (showRowNumbers) html += '<td></td>';
|
|
1994
|
+
columns.forEach(function(col) {
|
|
1995
|
+
var val = colFilterValues[col.key] || '';
|
|
1996
|
+
html += '<td><input type="search" data-col-filter="' + escHtml(col.key) + '" placeholder="' + escHtml(col.title) + '\u2026" value="' + escHtml(val) + '"></td>';
|
|
1997
|
+
});
|
|
1998
|
+
if (reverseRefs.length > 0) html += '<td></td>';
|
|
1999
|
+
html += '</tr>';
|
|
2000
|
+
}
|
|
2001
|
+
html += '</thead><tbody>';
|
|
2002
|
+
|
|
2003
|
+
displayRows.forEach(function(row, displayIndex) {
|
|
2004
|
+
var rowIdx = row[0];
|
|
2005
|
+
var isDirty = !!dirtyRows[rowIdx];
|
|
2006
|
+
var rowErrors = validationErrors[rowIdx];
|
|
2007
|
+
var rowCls = isDirty ? 'br-row-dirty' : (rowErrors ? 'br-row-invalid' : '');
|
|
2008
|
+
if (selectedRowIdx === rowIdx) rowCls = (rowCls ? rowCls + ' ' : '') + 'br-row-selected';
|
|
2009
|
+
html += '<tr data-row-idx="' + rowIdx + '"' + (rowCls ? ' class="' + rowCls + '"' : '') + '>';
|
|
2010
|
+
if (hasValidation) {
|
|
2011
|
+
if (rowErrors) {
|
|
2012
|
+
html += '<td class="br-val-cell"><span class="br-val-icon" data-val-row="' + rowIdx + '" title="Validation errors">\ud83d\udd25</span></td>';
|
|
2013
|
+
} else {
|
|
2014
|
+
html += '<td class="br-val-cell"></td>';
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
var rowCellInvalid = rowHasCellErrors(rowIdx);
|
|
2018
|
+
if (isDirty) {
|
|
2019
|
+
var saveCls = 'br-row-save' + (rowCellInvalid ? ' has-errors' : '');
|
|
2020
|
+
var saveTitle = rowCellInvalid ? 'Validation errors — click to view' : 'Save';
|
|
2021
|
+
html += '<td class="br-row-actions">' +
|
|
2022
|
+
'<button class="' + saveCls + '" title="' + saveTitle + '" data-action="save" data-row="' + rowIdx + '">\u2714</button>' +
|
|
2023
|
+
'<button class="br-row-cancel" title="Cancel" data-action="cancel" data-row="' + rowIdx + '">\u2718</button>' +
|
|
2024
|
+
'</td>';
|
|
2025
|
+
} else {
|
|
2026
|
+
html += '<td class="br-row-actions"></td>';
|
|
2027
|
+
}
|
|
2028
|
+
if (showRowNumbers) html += '<td style="color:#999;font-size:0.75rem;text-align:right;white-space:nowrap;">' + (rowIdx + 1) + '</td>';
|
|
2029
|
+
for (var i = 1; i < row.length; i++) {
|
|
2030
|
+
var val = row[i];
|
|
2031
|
+
var colKey = columns[i - 1].key;
|
|
2032
|
+
if (dirtyRows[rowIdx] && dirtyRows[rowIdx][colKey] !== undefined) val = dirtyRows[rowIdx][colKey];
|
|
2033
|
+
var cellErrKey = rowIdx + ':' + colKey;
|
|
2034
|
+
var cellHasError = !!cellValidationErrors[cellErrKey];
|
|
2035
|
+
var cellErrCls = cellHasError ? ' cell-invalid' : '';
|
|
2036
|
+
var cellErrTitle = cellHasError ? ' title="' + escHtml(cellValidationErrors[cellErrKey]) + '"' : '';
|
|
2037
|
+
var tdStyle = cellStyles[i - 1];
|
|
2038
|
+
if (val === null || val === undefined || val === '') {
|
|
2039
|
+
html += '<td class="null' + cellErrCls + '" data-col="' + (i - 1) + '"' + cellErrTitle + tdStyle + '>\u2014</td>';
|
|
2040
|
+
} else {
|
|
2041
|
+
var displayVal = String(val);
|
|
2042
|
+
if (!editMode && isCurrencyCol(columns[i - 1])) displayVal = formatCurrency(val);
|
|
2043
|
+
var refMap = refs[colKey];
|
|
2044
|
+
var colRef = columns[i - 1].references;
|
|
2045
|
+
if (refMap && refMap[displayVal]) {
|
|
2046
|
+
displayVal = refMap[displayVal];
|
|
2047
|
+
var fkClass = (!editMode && colRef) ? ' fk-link' : '';
|
|
2048
|
+
var fkParts = [];
|
|
2049
|
+
if (colRef && colRef.color) fkParts.push('background:' + escHtml(colRef.color) + '18');
|
|
2050
|
+
if (colWidths[colKey]) fkParts.push('white-space:normal');
|
|
2051
|
+
if (isRightAligned(columns[i - 1])) fkParts.push('text-align:right');
|
|
2052
|
+
var fkStyleAttr = fkParts.length > 0 ? ' style="' + fkParts.join(';') + '"' : '';
|
|
2053
|
+
var fkTitle = cellHasError ? escHtml(cellValidationErrors[cellErrKey]) : 'ID: ' + escHtml(String(val));
|
|
2054
|
+
html += '<td data-col="' + (i - 1) + '" class="' + (fkClass ? 'fk-link' : '') + cellErrCls + '"' + fkStyleAttr + ' title="' + fkTitle + '">' + escHtml(displayVal) + '</td>';
|
|
2055
|
+
} else {
|
|
2056
|
+
html += '<td data-col="' + (i - 1) + '"' + (cellErrCls ? ' class="' + cellErrCls.trim() + '"' : '') + cellErrTitle + tdStyle + '>' + escHtml(displayVal) + '</td>';
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
if (reverseRefs.length > 0) {
|
|
2061
|
+
html += '<td class="br-reverse-refs">';
|
|
2062
|
+
reverseRefs.forEach(function(rr, ri) {
|
|
2063
|
+
// Find the value of the referenced column in this row
|
|
2064
|
+
var refColIdx = columns.findIndex(function(c) { return c.key === rr.references_column; });
|
|
2065
|
+
var refVal = refColIdx >= 0 ? row[refColIdx + 1] : null;
|
|
2066
|
+
if (refVal !== null && refVal !== undefined) {
|
|
2067
|
+
if (ri > 0) html += ' ';
|
|
2068
|
+
var rrStyle = rr.color ? ' style="background:' + escHtml(rr.color) + '"' : '';
|
|
2069
|
+
html += '<a class="br-rr-link" data-rr-table="' + escHtml(rr.table) + '" data-rr-column="' + escHtml(rr.column) + '" data-rr-val="' + escHtml(String(refVal)) + '"' + rrStyle + '>' + escHtml(rr.title) + '</a>';
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
html += '</td>';
|
|
2073
|
+
}
|
|
2074
|
+
html += '</tr>';
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
html += '</tbody></table>';
|
|
2078
|
+
tableWrap.innerHTML = html;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
tableWrap.addEventListener('click', function(e) {
|
|
2082
|
+
if (e.target.closest('.br-col-resize')) return;
|
|
2083
|
+
var th = e.target.closest('th[data-sort-col]');
|
|
2084
|
+
if (th) {
|
|
2085
|
+
var ci = parseInt(th.getAttribute('data-sort-col'));
|
|
2086
|
+
if (ci === sortCol) {
|
|
2087
|
+
if (sortDirection === 1) sortDirection = -1;
|
|
2088
|
+
else if (sortDirection === -1) { sortDirection = 0; sortCol = -1; }
|
|
2089
|
+
else sortDirection = 1;
|
|
2090
|
+
} else { sortCol = ci; sortDirection = 1; }
|
|
2091
|
+
render();
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
var actionBtn = e.target.closest('[data-action]');
|
|
2096
|
+
if (actionBtn) {
|
|
2097
|
+
var action = actionBtn.getAttribute('data-action');
|
|
2098
|
+
var rowIdx = parseInt(actionBtn.getAttribute('data-row'));
|
|
2099
|
+
if (action === 'cancel') {
|
|
2100
|
+
delete dirtyRows[rowIdx];
|
|
2101
|
+
// Clear cell validation errors for this row
|
|
2102
|
+
var prefix = rowIdx + ':';
|
|
2103
|
+
Object.keys(cellValidationErrors).forEach(function(k) {
|
|
2104
|
+
if (k.indexOf(prefix) === 0) delete cellValidationErrors[k];
|
|
2105
|
+
});
|
|
2106
|
+
render();
|
|
2107
|
+
}
|
|
2108
|
+
else if (action === 'save') {
|
|
2109
|
+
if (rowHasCellErrors(rowIdx)) {
|
|
2110
|
+
var errors = getRowCellErrors(rowIdx);
|
|
2111
|
+
showValidationPopup(errors, actionBtn);
|
|
2112
|
+
} else {
|
|
2113
|
+
saveRow(dbKey, data.table, rowIdx);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
var rrLink = e.target.closest('.br-rr-link');
|
|
2120
|
+
if (rrLink) {
|
|
2121
|
+
var rrTable = rrLink.getAttribute('data-rr-table');
|
|
2122
|
+
var rrColumn = rrLink.getAttribute('data-rr-column');
|
|
2123
|
+
var rrVal = rrLink.getAttribute('data-rr-val');
|
|
2124
|
+
openFkTable(dbKey, rrTable, rrColumn, rrVal);
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
var td = e.target.closest('td[data-col]');
|
|
2129
|
+
if (!td) return;
|
|
2130
|
+
if (editMode) {
|
|
2131
|
+
if (!td.querySelector('.br-cell-edit')) startEdit(td);
|
|
2132
|
+
} else {
|
|
2133
|
+
var colIdx = parseInt(td.getAttribute('data-col'));
|
|
2134
|
+
var colRef = columns[colIdx].references;
|
|
2135
|
+
if (colRef) {
|
|
2136
|
+
// FK cells navigate to referenced table
|
|
2137
|
+
var tr = td.closest('tr');
|
|
2138
|
+
var rowIdx = parseInt(tr.getAttribute('data-row-idx'));
|
|
2139
|
+
var origRow = originalRows.find(function(r) { return r[0] === rowIdx; });
|
|
2140
|
+
if (!origRow) return;
|
|
2141
|
+
var rawVal = origRow[colIdx + 1];
|
|
2142
|
+
if (rawVal === null || rawVal === undefined) return;
|
|
2143
|
+
openFkTable(dbKey, colRef.table, colRef.column, String(rawVal));
|
|
2144
|
+
} else {
|
|
2145
|
+
// Non-FK cells select the row
|
|
2146
|
+
var tr = td.closest('tr');
|
|
2147
|
+
var clickedRowIdx = parseInt(tr.getAttribute('data-row-idx'));
|
|
2148
|
+
var prev = tableWrap.querySelector('tr.br-row-selected');
|
|
2149
|
+
if (prev && prev !== tr) prev.classList.remove('br-row-selected');
|
|
2150
|
+
if (selectedRowIdx === clickedRowIdx) {
|
|
2151
|
+
selectedRowIdx = null;
|
|
2152
|
+
tr.classList.remove('br-row-selected');
|
|
2153
|
+
} else {
|
|
2154
|
+
selectedRowIdx = clickedRowIdx;
|
|
2155
|
+
tr.classList.add('br-row-selected');
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
tableWrap.addEventListener('dblclick', function(e) {
|
|
2162
|
+
if (e.target.closest('.br-col-resize')) {
|
|
2163
|
+
// Double-click resize handle: auto-size column (clear saved width)
|
|
2164
|
+
var ci = parseInt(e.target.getAttribute('data-resize-col'));
|
|
2165
|
+
var colKey = columns[ci].key;
|
|
2166
|
+
delete colWidths[colKey];
|
|
2167
|
+
renderTableHTML();
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
if (editMode) return;
|
|
2171
|
+
var td = e.target.closest('td[data-col]');
|
|
2172
|
+
if (!td) return;
|
|
2173
|
+
var colIdx = parseInt(td.getAttribute('data-col'));
|
|
2174
|
+
if (columns[colIdx].references) return;
|
|
2175
|
+
var tr = td.closest('tr');
|
|
2176
|
+
var rowIdx = parseInt(tr.getAttribute('data-row-idx'));
|
|
2177
|
+
openRowEditor(rowIdx);
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
// Column resize drag
|
|
2181
|
+
tableWrap.addEventListener('mousedown', function(e) {
|
|
2182
|
+
var handle = e.target.closest('.br-col-resize');
|
|
2183
|
+
if (!handle) return;
|
|
2184
|
+
e.preventDefault();
|
|
2185
|
+
e.stopPropagation();
|
|
2186
|
+
var ci = parseInt(handle.getAttribute('data-resize-col'));
|
|
2187
|
+
var th = handle.parentElement;
|
|
2188
|
+
var startX = e.clientX;
|
|
2189
|
+
var startW = th.offsetWidth;
|
|
2190
|
+
handle.classList.add('active');
|
|
2191
|
+
document.body.style.cursor = 'col-resize';
|
|
2192
|
+
document.body.style.userSelect = 'none';
|
|
2193
|
+
|
|
2194
|
+
function onMove(ev) {
|
|
2195
|
+
var newW = Math.max(30, startW + ev.clientX - startX);
|
|
2196
|
+
th.style.width = newW + 'px';
|
|
2197
|
+
th.style.minWidth = newW + 'px';
|
|
2198
|
+
th.style.maxWidth = newW + 'px';
|
|
2199
|
+
}
|
|
2200
|
+
function onUp(ev) {
|
|
2201
|
+
document.removeEventListener('mousemove', onMove);
|
|
2202
|
+
document.removeEventListener('mouseup', onUp);
|
|
2203
|
+
handle.classList.remove('active');
|
|
2204
|
+
document.body.style.cursor = '';
|
|
2205
|
+
document.body.style.userSelect = '';
|
|
2206
|
+
var finalW = Math.max(30, startW + ev.clientX - startX);
|
|
2207
|
+
colWidths[columns[ci].key] = finalW;
|
|
2208
|
+
renderTableHTML();
|
|
2209
|
+
}
|
|
2210
|
+
document.addEventListener('mousemove', onMove);
|
|
2211
|
+
document.addEventListener('mouseup', onUp);
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
function isDatePatternCol(col) {
|
|
2215
|
+
var c = col.constraints || {};
|
|
2216
|
+
return !c.format && c.pattern &&
|
|
2217
|
+
/^\^?\\d\{[12](,2)?\}[\/\-]\\d\{[12](,2)?\}[\/\-]\\d\{[24]\}\$?$/.test(c.pattern);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function datePatternZeroPad(col) {
|
|
2221
|
+
var c = col.constraints || {};
|
|
2222
|
+
return c.pattern && /\\d\{2\}/.test(c.pattern);
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
function toIsoDate(val) {
|
|
2226
|
+
if (!val) return '';
|
|
2227
|
+
var parts = val.split('/');
|
|
2228
|
+
if (parts.length === 3) return parts[2] + '-' + parts[0].padStart(2, '0') + '-' + parts[1].padStart(2, '0');
|
|
2229
|
+
return val;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function fromIsoDate(val, zeroPad) {
|
|
2233
|
+
if (!val) return '';
|
|
2234
|
+
var d = val.split('-');
|
|
2235
|
+
if (d.length !== 3) return val;
|
|
2236
|
+
var m = zeroPad ? d[1] : String(parseInt(d[1]));
|
|
2237
|
+
var day = zeroPad ? d[2] : String(parseInt(d[2]));
|
|
2238
|
+
return m + '/' + day + '/' + d[0];
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
function validateField(col, value) {
|
|
2242
|
+
var c = col.constraints || {};
|
|
2243
|
+
var isRequired = requiredFields.indexOf(col.key) >= 0;
|
|
2244
|
+
|
|
2245
|
+
if (isRequired && (value === '' || value === null || value === undefined)) {
|
|
2246
|
+
return col.title + ' is required';
|
|
2247
|
+
}
|
|
2248
|
+
if (value === '' || value === null || value === undefined) return null;
|
|
2249
|
+
|
|
2250
|
+
if (col.type === 'integer') {
|
|
2251
|
+
if (!/^-?\d+$/.test(value)) return col.title + ' must be an integer';
|
|
2252
|
+
var n = parseInt(value);
|
|
2253
|
+
if (c.minimum !== undefined && n < c.minimum) return col.title + ' must be at least ' + c.minimum;
|
|
2254
|
+
if (c.maximum !== undefined && n > c.maximum) return col.title + ' must be at most ' + c.maximum;
|
|
2255
|
+
} else if (col.type === 'number') {
|
|
2256
|
+
if (isNaN(parseFloat(value))) return col.title + ' must be a number';
|
|
2257
|
+
var n = parseFloat(value);
|
|
2258
|
+
if (c.minimum !== undefined && n < c.minimum) return col.title + ' must be at least ' + c.minimum;
|
|
2259
|
+
if (c.maximum !== undefined && n > c.maximum) return col.title + ' must be at most ' + c.maximum;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
if (c.enum && c.enum.indexOf(value) < 0) {
|
|
2263
|
+
return col.title + ' must be one of: ' + c.enum.join(', ');
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
if (c.pattern && !isDatePatternCol(col)) {
|
|
2267
|
+
try {
|
|
2268
|
+
var re = new RegExp(c.pattern);
|
|
2269
|
+
if (!re.test(value)) return col.title + ' does not match expected format';
|
|
2270
|
+
} catch(e) {}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
if (c.format === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
2274
|
+
return col.title + ' must be a valid date (YYYY-MM-DD)';
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
if (c.format === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
2278
|
+
return col.title + ' must be a valid email';
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
if (c.minLength !== undefined && value.length < c.minLength) {
|
|
2282
|
+
return col.title + ' must be at least ' + c.minLength + ' characters';
|
|
2283
|
+
}
|
|
2284
|
+
if (c.maxLength !== undefined && value.length > c.maxLength) {
|
|
2285
|
+
return col.title + ' must be at most ' + c.maxLength + ' characters';
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
return null;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function hasAnyCellErrors() {
|
|
2292
|
+
return Object.keys(cellValidationErrors).length > 0;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function rowHasCellErrors(rowIdx) {
|
|
2296
|
+
var prefix = rowIdx + ':';
|
|
2297
|
+
for (var key in cellValidationErrors) {
|
|
2298
|
+
if (key.indexOf(prefix) === 0) return true;
|
|
2299
|
+
}
|
|
2300
|
+
return false;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function getRowCellErrors(rowIdx) {
|
|
2304
|
+
var errors = [];
|
|
2305
|
+
var prefix = rowIdx + ':';
|
|
2306
|
+
for (var key in cellValidationErrors) {
|
|
2307
|
+
if (key.indexOf(prefix) === 0) errors.push(cellValidationErrors[key]);
|
|
2308
|
+
}
|
|
2309
|
+
return errors;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
function validateRow(rowIdx) {
|
|
2313
|
+
var origRow = originalRows.find(function(r) { return r[0] === rowIdx; });
|
|
2314
|
+
if (!origRow) return;
|
|
2315
|
+
columns.forEach(function(col, ci) {
|
|
2316
|
+
var val = origRow[ci + 1];
|
|
2317
|
+
if (dirtyRows[rowIdx] && dirtyRows[rowIdx][col.key] !== undefined) val = dirtyRows[rowIdx][col.key];
|
|
2318
|
+
if (val === null || val === undefined) val = '';
|
|
2319
|
+
var errKey = rowIdx + ':' + col.key;
|
|
2320
|
+
var error = validateField(col, String(val));
|
|
2321
|
+
if (error) {
|
|
2322
|
+
cellValidationErrors[errKey] = error;
|
|
2323
|
+
} else {
|
|
2324
|
+
delete cellValidationErrors[errKey];
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function clearRowValidation(rowIdx) {
|
|
2330
|
+
// Clear cell-level validation errors for this row
|
|
2331
|
+
var prefix = rowIdx + ':';
|
|
2332
|
+
Object.keys(cellValidationErrors).forEach(function(k) {
|
|
2333
|
+
if (k.indexOf(prefix) === 0) delete cellValidationErrors[k];
|
|
2334
|
+
});
|
|
2335
|
+
serverCellErrorKeys = serverCellErrorKeys.filter(function(k) { return k.indexOf(prefix) !== 0; });
|
|
2336
|
+
// Clear bulk validation errors for this row
|
|
2337
|
+
delete validationErrors[rowIdx];
|
|
2338
|
+
hasValidation = Object.keys(validationErrors).length > 0;
|
|
2339
|
+
updateValidateBtn();
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
function updateValidateBtn() {
|
|
2343
|
+
var count = Object.keys(validationErrors).length;
|
|
2344
|
+
validateBtn.title = count > 0 ? count + ' row(s) with errors' : 'All rows valid';
|
|
2345
|
+
if (count === 0) validateBtn.classList.remove('edit-mode');
|
|
2346
|
+
else validateBtn.classList.add('edit-mode');
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function showValidationPopup(errors, anchorEl) {
|
|
2350
|
+
// Remove any existing validation tooltip
|
|
2351
|
+
var existing = document.querySelector('.br-validation-tooltip');
|
|
2352
|
+
if (existing) existing.remove();
|
|
2353
|
+
|
|
2354
|
+
var tip = document.createElement('div');
|
|
2355
|
+
tip.className = 'br-validation-tooltip';
|
|
2356
|
+
errors.forEach(function(err) {
|
|
2357
|
+
var line = document.createElement('div');
|
|
2358
|
+
line.textContent = '\u2022 ' + err;
|
|
2359
|
+
tip.appendChild(line);
|
|
2360
|
+
});
|
|
2361
|
+
|
|
2362
|
+
document.body.appendChild(tip);
|
|
2363
|
+
var rect = anchorEl.getBoundingClientRect();
|
|
2364
|
+
tip.style.left = (rect.left + rect.width / 2 - tip.offsetWidth / 2) + 'px';
|
|
2365
|
+
tip.style.top = (rect.bottom + 6) + 'px';
|
|
2366
|
+
|
|
2367
|
+
// Dismiss on click outside
|
|
2368
|
+
setTimeout(function() {
|
|
2369
|
+
function dismiss() { tip.remove(); document.removeEventListener('click', dismiss, true); }
|
|
2370
|
+
document.addEventListener('click', dismiss, true);
|
|
2371
|
+
}, 0);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function startEdit(td) {
|
|
2375
|
+
commitEdit();
|
|
2376
|
+
var tr = td.closest('tr');
|
|
2377
|
+
var rowIdx = parseInt(tr.getAttribute('data-row-idx'));
|
|
2378
|
+
var colIdx = parseInt(td.getAttribute('data-col'));
|
|
2379
|
+
var col = columns[colIdx];
|
|
2380
|
+
var colKey = col.key;
|
|
2381
|
+
var c = col.constraints || {};
|
|
2382
|
+
|
|
2383
|
+
// Validate the entire row on first click (highlights existing errors)
|
|
2384
|
+
if (!rowHasCellErrors(rowIdx) && !dirtyRows[rowIdx]) {
|
|
2385
|
+
validateRow(rowIdx);
|
|
2386
|
+
if (rowHasCellErrors(rowIdx)) {
|
|
2387
|
+
render();
|
|
2388
|
+
// Re-find the td after render rebuilt the DOM
|
|
2389
|
+
var newTr = tableWrap.querySelector('tr[data-row-idx="' + rowIdx + '"]');
|
|
2390
|
+
td = newTr ? newTr.querySelector('td[data-col="' + colIdx + '"]') : null;
|
|
2391
|
+
if (!td) return;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
var colRef = col.references;
|
|
2395
|
+
|
|
2396
|
+
var currentVal;
|
|
2397
|
+
if (dirtyRows[rowIdx] && dirtyRows[rowIdx][colKey] !== undefined) {
|
|
2398
|
+
currentVal = dirtyRows[rowIdx][colKey];
|
|
2399
|
+
} else {
|
|
2400
|
+
var origRow = originalRows.find(function(r) { return r[0] === rowIdx; });
|
|
2401
|
+
currentVal = origRow ? origRow[colIdx + 1] : '';
|
|
2402
|
+
}
|
|
2403
|
+
if (currentVal === null || currentVal === undefined) currentVal = '';
|
|
2404
|
+
currentVal = String(currentVal);
|
|
2405
|
+
|
|
2406
|
+
var inputEl;
|
|
2407
|
+
var isDatePattern = isDatePatternCol(col);
|
|
2408
|
+
|
|
2409
|
+
if (c.enum) {
|
|
2410
|
+
inputEl = document.createElement('select');
|
|
2411
|
+
inputEl.className = 'br-cell-edit';
|
|
2412
|
+
if (requiredFields.indexOf(colKey) < 0) {
|
|
2413
|
+
var emptyOpt = document.createElement('option');
|
|
2414
|
+
emptyOpt.value = '';
|
|
2415
|
+
emptyOpt.textContent = '\u2014';
|
|
2416
|
+
inputEl.appendChild(emptyOpt);
|
|
2417
|
+
}
|
|
2418
|
+
c.enum.forEach(function(opt) {
|
|
2419
|
+
var option = document.createElement('option');
|
|
2420
|
+
option.value = opt;
|
|
2421
|
+
option.textContent = opt;
|
|
2422
|
+
if (String(opt) === currentVal) option.selected = true;
|
|
2423
|
+
inputEl.appendChild(option);
|
|
2424
|
+
});
|
|
2425
|
+
} else if (colRef) {
|
|
2426
|
+
var refMap = refs[colKey] || {};
|
|
2427
|
+
inputEl = document.createElement('select');
|
|
2428
|
+
inputEl.className = 'br-cell-edit';
|
|
2429
|
+
var emptyOpt = document.createElement('option');
|
|
2430
|
+
emptyOpt.value = '';
|
|
2431
|
+
emptyOpt.textContent = '\u2014';
|
|
2432
|
+
inputEl.appendChild(emptyOpt);
|
|
2433
|
+
Object.keys(refMap).forEach(function(id) {
|
|
2434
|
+
var option = document.createElement('option');
|
|
2435
|
+
option.value = id;
|
|
2436
|
+
option.textContent = refMap[id] + ' (' + id + ')';
|
|
2437
|
+
if (id === currentVal) option.selected = true;
|
|
2438
|
+
inputEl.appendChild(option);
|
|
2439
|
+
});
|
|
2440
|
+
} else if (c.format === 'date') {
|
|
2441
|
+
inputEl = document.createElement('input');
|
|
2442
|
+
inputEl.className = 'br-cell-edit';
|
|
2443
|
+
inputEl.type = 'date';
|
|
2444
|
+
inputEl.value = currentVal;
|
|
2445
|
+
} else if (isDatePattern) {
|
|
2446
|
+
inputEl = document.createElement('input');
|
|
2447
|
+
inputEl.className = 'br-cell-edit';
|
|
2448
|
+
inputEl.type = 'date';
|
|
2449
|
+
inputEl.value = toIsoDate(currentVal);
|
|
2450
|
+
inputEl.setAttribute('data-date-pattern', datePatternZeroPad(col) ? 'pad' : 'nopad');
|
|
2451
|
+
} else if (col.type === 'integer' || col.type === 'number') {
|
|
2452
|
+
inputEl = document.createElement('input');
|
|
2453
|
+
inputEl.className = 'br-cell-edit';
|
|
2454
|
+
inputEl.type = 'number';
|
|
2455
|
+
inputEl.step = col.type === 'integer' ? '1' : 'any';
|
|
2456
|
+
if (c.minimum !== undefined) inputEl.min = c.minimum;
|
|
2457
|
+
if (c.maximum !== undefined) inputEl.max = c.maximum;
|
|
2458
|
+
inputEl.value = currentVal;
|
|
2459
|
+
} else if (c.format === 'email') {
|
|
2460
|
+
inputEl = document.createElement('input');
|
|
2461
|
+
inputEl.className = 'br-cell-edit';
|
|
2462
|
+
inputEl.type = 'email';
|
|
2463
|
+
inputEl.value = currentVal;
|
|
2464
|
+
} else {
|
|
2465
|
+
inputEl = document.createElement('input');
|
|
2466
|
+
inputEl.className = 'br-cell-edit';
|
|
2467
|
+
inputEl.type = 'text';
|
|
2468
|
+
inputEl.value = currentVal;
|
|
2469
|
+
if (c.maxLength) inputEl.maxLength = c.maxLength;
|
|
2470
|
+
if (c.pattern) inputEl.pattern = c.pattern;
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
td.textContent = '';
|
|
2474
|
+
td.classList.remove('null');
|
|
2475
|
+
td.appendChild(inputEl);
|
|
2476
|
+
inputEl.focus();
|
|
2477
|
+
if (inputEl.select) inputEl.select();
|
|
2478
|
+
|
|
2479
|
+
editingCell = { tr: tr, colIdx: colIdx, rowIdx: rowIdx, input: inputEl, td: td };
|
|
2480
|
+
|
|
2481
|
+
inputEl.addEventListener('keydown', function(ev) {
|
|
2482
|
+
if (ev.key === 'Enter') { ev.preventDefault(); commitEdit(); }
|
|
2483
|
+
else if (ev.key === 'Tab') {
|
|
2484
|
+
ev.preventDefault();
|
|
2485
|
+
var nextColIdx = colIdx + (ev.shiftKey ? -1 : 1);
|
|
2486
|
+
commitEdit();
|
|
2487
|
+
var nextTd = tr.querySelector('td[data-col="' + nextColIdx + '"]');
|
|
2488
|
+
if (nextTd) startEdit(nextTd);
|
|
2489
|
+
} else if (ev.key === 'Escape') { ev.preventDefault(); cancelEdit(); }
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
inputEl.addEventListener('blur', function() {
|
|
2493
|
+
setTimeout(function() { commitEdit(); }, 100);
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
function commitEdit() {
|
|
2498
|
+
if (!editingCell) return;
|
|
2499
|
+
var ec = editingCell;
|
|
2500
|
+
editingCell = null;
|
|
2501
|
+
var input = ec.input;
|
|
2502
|
+
if (!input.parentNode) return;
|
|
2503
|
+
|
|
2504
|
+
var col = columns[ec.colIdx];
|
|
2505
|
+
var colKey = col.key;
|
|
2506
|
+
var newVal = input.value;
|
|
2507
|
+
|
|
2508
|
+
// Convert date-pattern fields from ISO back to display format for storage
|
|
2509
|
+
var dp = input.getAttribute('data-date-pattern');
|
|
2510
|
+
var valForStorage = newVal;
|
|
2511
|
+
if (dp && newVal) {
|
|
2512
|
+
valForStorage = fromIsoDate(newVal, dp === 'pad');
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// Validate the field (use the storage value for validation)
|
|
2516
|
+
var errKey = ec.rowIdx + ':' + colKey;
|
|
2517
|
+
var error = validateField(col, valForStorage);
|
|
2518
|
+
if (error) {
|
|
2519
|
+
cellValidationErrors[errKey] = error;
|
|
2520
|
+
} else {
|
|
2521
|
+
delete cellValidationErrors[errKey];
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
var origRow = originalRows.find(function(r) { return r[0] === ec.rowIdx; });
|
|
2525
|
+
var origVal = origRow ? origRow[ec.colIdx + 1] : null;
|
|
2526
|
+
if (origVal === null || origVal === undefined) origVal = '';
|
|
2527
|
+
|
|
2528
|
+
if (String(valForStorage) !== String(origVal)) {
|
|
2529
|
+
if (!dirtyRows[ec.rowIdx]) dirtyRows[ec.rowIdx] = {};
|
|
2530
|
+
dirtyRows[ec.rowIdx][colKey] = valForStorage;
|
|
2531
|
+
} else if (dirtyRows[ec.rowIdx]) {
|
|
2532
|
+
delete dirtyRows[ec.rowIdx][colKey];
|
|
2533
|
+
if (Object.keys(dirtyRows[ec.rowIdx]).length === 0) delete dirtyRows[ec.rowIdx];
|
|
2534
|
+
}
|
|
2535
|
+
render();
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
function cancelEdit() {
|
|
2539
|
+
if (!editingCell) return;
|
|
2540
|
+
editingCell = null;
|
|
2541
|
+
render();
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function saveRow(dbKey, tableKey, rowIdx) {
|
|
2545
|
+
var changes = dirtyRows[rowIdx];
|
|
2546
|
+
if (!changes) return;
|
|
2547
|
+
|
|
2548
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
2549
|
+
'/tables/' + encodeURIComponent(tableKey) +
|
|
2550
|
+
'/rows/' + rowIdx;
|
|
2551
|
+
|
|
2552
|
+
fetch(url, {
|
|
2553
|
+
method: 'PUT',
|
|
2554
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2555
|
+
body: JSON.stringify({ changes: changes })
|
|
2556
|
+
})
|
|
2557
|
+
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, status: r.status, data: j }; }); })
|
|
2558
|
+
.then(function(res) {
|
|
2559
|
+
if (res.status === 403) { showAdminRequired(res.data); return; }
|
|
2560
|
+
if (res.ok && res.data.valid) {
|
|
2561
|
+
var origRow = originalRows.find(function(r) { return r[0] === rowIdx; });
|
|
2562
|
+
if (origRow) {
|
|
2563
|
+
Object.keys(changes).forEach(function(colKey) {
|
|
2564
|
+
var ci = columns.findIndex(function(c) { return c.key === colKey; });
|
|
2565
|
+
if (ci >= 0) {
|
|
2566
|
+
var coerced = changes[colKey];
|
|
2567
|
+
var colType = columns[ci].type;
|
|
2568
|
+
if (colType === 'integer') coerced = parseInt(coerced) || coerced;
|
|
2569
|
+
else if (colType === 'number') coerced = parseFloat(coerced) || coerced;
|
|
2570
|
+
origRow[ci + 1] = coerced;
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
delete dirtyRows[rowIdx];
|
|
2575
|
+
clearRowValidation(rowIdx);
|
|
2576
|
+
render();
|
|
2577
|
+
} else {
|
|
2578
|
+
var rawErrors = res.data.errors || [{message: 'Validation failed'}];
|
|
2579
|
+
var msg = rawErrors.map(function(e) { return e.message || e; }).join('\n');
|
|
2580
|
+
alert('Validation error:\n' + msg);
|
|
2581
|
+
}
|
|
2582
|
+
})
|
|
2583
|
+
.catch(function(err) { alert('Save failed: ' + err.message); });
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function openFkTable(fkDbKey, refTableKey, refColumn, rawVal) {
|
|
2587
|
+
// Fetch the referenced table with "all" view, then open it with column filter pre-filled
|
|
2588
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(fkDbKey) +
|
|
2589
|
+
'/tables/' + encodeURIComponent(refTableKey) + '?view=all';
|
|
2590
|
+
|
|
2591
|
+
fetch(url)
|
|
2592
|
+
.then(function(r) { return r.json(); })
|
|
2593
|
+
.then(function(refData) {
|
|
2594
|
+
var pos = nextPosition();
|
|
2595
|
+
var popupId = 'fk:' + fkDbKey + ':' + refTableKey + ':' + refColumn + ':' + rawVal;
|
|
2596
|
+
var existing = findPopup(popupId);
|
|
2597
|
+
if (existing) { bringToFront(existing); return; }
|
|
2598
|
+
var fkPopup = createPopup({
|
|
2599
|
+
id: popupId,
|
|
2600
|
+
title: refData.views[0] ? (refTableKey + ' \u2014 ' + refData.views[0].title) : refTableKey,
|
|
2601
|
+
pinned: true, wide: true, x: pos.x, y: pos.y,
|
|
2602
|
+
color: refData.color
|
|
2603
|
+
});
|
|
2604
|
+
// Pre-configure column filters using the display value (FK-resolved) if available
|
|
2605
|
+
var filterVal = rawVal;
|
|
2606
|
+
var refRefs = refData.references || {};
|
|
2607
|
+
if (refRefs[refColumn] && refRefs[refColumn][rawVal]) {
|
|
2608
|
+
filterVal = refRefs[refColumn][rawVal];
|
|
2609
|
+
}
|
|
2610
|
+
var preColFilters = {};
|
|
2611
|
+
preColFilters[refColumn] = '^' + filterVal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$';
|
|
2612
|
+
initCsvTableView(fkPopup, refData, fkDbKey, { colFiltersVisible: true, colFilterValues: preColFilters });
|
|
2613
|
+
})
|
|
2614
|
+
.catch(function() {});
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function openRowEditor(rowIdx) {
|
|
2618
|
+
var origRow = originalRows.find(function(r) { return r[0] === rowIdx; });
|
|
2619
|
+
if (!origRow) return;
|
|
2620
|
+
|
|
2621
|
+
// Fetch full table data (all columns with constraints) for the editor
|
|
2622
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
2623
|
+
'/tables/' + encodeURIComponent(data.table) + '?view=all';
|
|
2624
|
+
fetch(url)
|
|
2625
|
+
.then(function(r) { return r.json(); })
|
|
2626
|
+
.then(function(fullData) {
|
|
2627
|
+
var allCols = fullData.columns;
|
|
2628
|
+
var allRefs = fullData.references || {};
|
|
2629
|
+
var requiredFields = fullData.required || [];
|
|
2630
|
+
// Find the row in the full data by rowIdx
|
|
2631
|
+
var fullRow = fullData.rows.find(function(r) { return r[0] === rowIdx; });
|
|
2632
|
+
if (!fullRow) return;
|
|
2633
|
+
|
|
2634
|
+
var pos = nextPosition();
|
|
2635
|
+
var popupId = 'row-edit:' + dbKey + ':' + data.table + ':' + rowIdx;
|
|
2636
|
+
var existing = findPopup(popupId);
|
|
2637
|
+
if (existing) { bringToFront(existing); return; }
|
|
2638
|
+
|
|
2639
|
+
// Build a display label from the first non-id text column
|
|
2640
|
+
var displayLabel = '';
|
|
2641
|
+
for (var i = 0; i < allCols.length; i++) {
|
|
2642
|
+
if (allCols[i].type === 'string' && allCols[i].key !== 'id') {
|
|
2643
|
+
var v = fullRow[i + 1];
|
|
2644
|
+
if (v) { displayLabel = String(v); break; }
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
var titleText = (data.table) + ' — ' + (displayLabel || 'Row ' + (rowIdx + 1));
|
|
2648
|
+
|
|
2649
|
+
var editorPopup = createPopup({
|
|
2650
|
+
id: popupId,
|
|
2651
|
+
title: titleText,
|
|
2652
|
+
pinned: true, x: pos.x, y: pos.y, width: 480,
|
|
2653
|
+
color: data.color
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
var body = editorPopup.querySelector('.br-popup-body');
|
|
2657
|
+
body.style.padding = '0';
|
|
2658
|
+
body.style.overflowY = 'auto';
|
|
2659
|
+
|
|
2660
|
+
var form = document.createElement('div');
|
|
2661
|
+
form.className = 'br-row-editor';
|
|
2662
|
+
|
|
2663
|
+
var editorState = {};
|
|
2664
|
+
|
|
2665
|
+
allCols.forEach(function(col, ci) {
|
|
2666
|
+
var val = fullRow[ci + 1];
|
|
2667
|
+
var origVal = (val === null || val === undefined) ? '' : String(val);
|
|
2668
|
+
editorState[col.key] = origVal;
|
|
2669
|
+
|
|
2670
|
+
var fieldDiv = document.createElement('div');
|
|
2671
|
+
fieldDiv.className = 'br-row-editor-field';
|
|
2672
|
+
|
|
2673
|
+
// Label
|
|
2674
|
+
var label = document.createElement('div');
|
|
2675
|
+
label.className = 'br-row-editor-label';
|
|
2676
|
+
if (requiredFields.indexOf(col.key) >= 0) label.className += ' required';
|
|
2677
|
+
label.textContent = col.title;
|
|
2678
|
+
var constraints = col.constraints || {};
|
|
2679
|
+
if (constraints.description) {
|
|
2680
|
+
label.title = constraints.description;
|
|
2681
|
+
} else {
|
|
2682
|
+
// Build a tooltip from constraints
|
|
2683
|
+
var tips = [];
|
|
2684
|
+
if (col.type === 'integer') tips.push('Integer');
|
|
2685
|
+
else if (col.type === 'number') tips.push('Number');
|
|
2686
|
+
else tips.push('Text');
|
|
2687
|
+
if (constraints.format) tips.push('Format: ' + constraints.format);
|
|
2688
|
+
if (constraints.pattern) tips.push('Pattern: ' + constraints.pattern);
|
|
2689
|
+
if (constraints.minimum !== undefined) tips.push('Min: ' + constraints.minimum);
|
|
2690
|
+
if (constraints.maximum !== undefined) tips.push('Max: ' + constraints.maximum);
|
|
2691
|
+
if (constraints.minLength !== undefined) tips.push('Min length: ' + constraints.minLength);
|
|
2692
|
+
if (constraints.maxLength !== undefined) tips.push('Max length: ' + constraints.maxLength);
|
|
2693
|
+
if (constraints.enum) tips.push('Options: ' + constraints.enum.join(', '));
|
|
2694
|
+
if (col.references) tips.push('References: ' + col.references.table + '.' + col.references.display);
|
|
2695
|
+
if (tips.length > 0) label.title = tips.join('\n');
|
|
2696
|
+
}
|
|
2697
|
+
fieldDiv.appendChild(label);
|
|
2698
|
+
|
|
2699
|
+
// Input
|
|
2700
|
+
var inputWrap = document.createElement('div');
|
|
2701
|
+
inputWrap.className = 'br-row-editor-input';
|
|
2702
|
+
|
|
2703
|
+
var isDatePattern = !constraints.format && constraints.pattern &&
|
|
2704
|
+
/^\^?\\d\{[12](,2)?\}[\/\-]\\d\{[12](,2)?\}[\/\-]\\d\{[24]\}\$?$/.test(constraints.pattern);
|
|
2705
|
+
|
|
2706
|
+
var inputEl;
|
|
2707
|
+
if (constraints.enum) {
|
|
2708
|
+
// Dropdown for enum
|
|
2709
|
+
inputEl = document.createElement('select');
|
|
2710
|
+
if (requiredFields.indexOf(col.key) < 0) {
|
|
2711
|
+
var emptyOpt = document.createElement('option');
|
|
2712
|
+
emptyOpt.value = '';
|
|
2713
|
+
emptyOpt.textContent = '—';
|
|
2714
|
+
inputEl.appendChild(emptyOpt);
|
|
2715
|
+
}
|
|
2716
|
+
constraints.enum.forEach(function(opt) {
|
|
2717
|
+
var option = document.createElement('option');
|
|
2718
|
+
option.value = opt;
|
|
2719
|
+
option.textContent = opt;
|
|
2720
|
+
if (String(opt) === origVal) option.selected = true;
|
|
2721
|
+
inputEl.appendChild(option);
|
|
2722
|
+
});
|
|
2723
|
+
} else if (col.references) {
|
|
2724
|
+
// Dropdown for FK references
|
|
2725
|
+
var refMap = allRefs[col.key] || {};
|
|
2726
|
+
inputEl = document.createElement('select');
|
|
2727
|
+
var emptyOpt = document.createElement('option');
|
|
2728
|
+
emptyOpt.value = '';
|
|
2729
|
+
emptyOpt.textContent = '—';
|
|
2730
|
+
inputEl.appendChild(emptyOpt);
|
|
2731
|
+
Object.keys(refMap).forEach(function(id) {
|
|
2732
|
+
var option = document.createElement('option');
|
|
2733
|
+
option.value = id;
|
|
2734
|
+
option.textContent = refMap[id] + ' (' + id + ')';
|
|
2735
|
+
if (id === origVal) option.selected = true;
|
|
2736
|
+
inputEl.appendChild(option);
|
|
2737
|
+
});
|
|
2738
|
+
} else if (constraints.format === 'date') {
|
|
2739
|
+
// Date input (ISO format)
|
|
2740
|
+
inputEl = document.createElement('input');
|
|
2741
|
+
inputEl.type = 'date';
|
|
2742
|
+
inputEl.value = origVal;
|
|
2743
|
+
} else if (isDatePattern) {
|
|
2744
|
+
// Date-like pattern (e.g. M/D/YYYY) — native date picker, convert on load/save
|
|
2745
|
+
inputEl = document.createElement('input');
|
|
2746
|
+
inputEl.type = 'date';
|
|
2747
|
+
inputEl._dateZeroPad = /\\d\{2\}/.test(constraints.pattern);
|
|
2748
|
+
// Convert M/D/YYYY → YYYY-MM-DD for the native input
|
|
2749
|
+
if (origVal) {
|
|
2750
|
+
var parts = origVal.split('/');
|
|
2751
|
+
if (parts.length === 3) inputEl.value = parts[2] + '-' + parts[0].padStart(2, '0') + '-' + parts[1].padStart(2, '0');
|
|
2752
|
+
}
|
|
2753
|
+
} else if (col.type === 'integer' || col.type === 'number') {
|
|
2754
|
+
inputEl = document.createElement('input');
|
|
2755
|
+
inputEl.type = 'number';
|
|
2756
|
+
if (col.type === 'integer') inputEl.step = '1';
|
|
2757
|
+
else inputEl.step = 'any';
|
|
2758
|
+
if (constraints.minimum !== undefined) inputEl.min = constraints.minimum;
|
|
2759
|
+
if (constraints.maximum !== undefined) inputEl.max = constraints.maximum;
|
|
2760
|
+
inputEl.value = origVal;
|
|
2761
|
+
} else if (constraints.format === 'email') {
|
|
2762
|
+
inputEl = document.createElement('input');
|
|
2763
|
+
inputEl.type = 'email';
|
|
2764
|
+
inputEl.value = origVal;
|
|
2765
|
+
} else {
|
|
2766
|
+
inputEl = document.createElement('input');
|
|
2767
|
+
inputEl.type = 'text';
|
|
2768
|
+
inputEl.value = origVal;
|
|
2769
|
+
if (constraints.maxLength) inputEl.maxLength = constraints.maxLength;
|
|
2770
|
+
if (constraints.pattern) inputEl.pattern = constraints.pattern;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
inputEl.setAttribute('data-col-key', col.key);
|
|
2774
|
+
// For date-pattern fields, store ISO value as orig since .value returns ISO
|
|
2775
|
+
if (isDatePattern) {
|
|
2776
|
+
inputEl.setAttribute('data-orig-val', inputEl.value);
|
|
2777
|
+
inputEl.setAttribute('data-date-pattern', inputEl._dateZeroPad ? 'pad' : 'nopad');
|
|
2778
|
+
} else {
|
|
2779
|
+
inputEl.setAttribute('data-orig-val', origVal);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
inputEl.addEventListener('input', function() {
|
|
2783
|
+
var curVal = inputEl.value;
|
|
2784
|
+
if (curVal !== inputEl.getAttribute('data-orig-val')) {
|
|
2785
|
+
inputEl.classList.add('dirty');
|
|
2786
|
+
} else {
|
|
2787
|
+
inputEl.classList.remove('dirty');
|
|
2788
|
+
}
|
|
2789
|
+
updateSaveBtn();
|
|
2790
|
+
});
|
|
2791
|
+
inputEl.addEventListener('change', function() {
|
|
2792
|
+
var curVal = inputEl.value;
|
|
2793
|
+
if (curVal !== inputEl.getAttribute('data-orig-val')) {
|
|
2794
|
+
inputEl.classList.add('dirty');
|
|
2795
|
+
} else {
|
|
2796
|
+
inputEl.classList.remove('dirty');
|
|
2797
|
+
}
|
|
2798
|
+
// Validate on change
|
|
2799
|
+
reValidateField(inputEl, col);
|
|
2800
|
+
updateSaveBtn();
|
|
2801
|
+
});
|
|
2802
|
+
inputEl.addEventListener('blur', function() {
|
|
2803
|
+
reValidateField(inputEl, col);
|
|
2804
|
+
updateSaveBtn();
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
inputWrap.appendChild(inputEl);
|
|
2808
|
+
fieldDiv.appendChild(inputWrap);
|
|
2809
|
+
|
|
2810
|
+
// Type icon
|
|
2811
|
+
var icon = document.createElement('div');
|
|
2812
|
+
icon.className = 'br-row-editor-type-icon';
|
|
2813
|
+
if (constraints.enum) icon.textContent = '\u2261'; // ≡ (list)
|
|
2814
|
+
else if (col.references) icon.textContent = '\u2192'; // → (arrow)
|
|
2815
|
+
else if (constraints.format === 'date' || isDatePattern) icon.textContent = '\ud83d\udcc5'; // 📅
|
|
2816
|
+
else if (constraints.format === 'email') icon.textContent = '@';
|
|
2817
|
+
else if (col.type === 'integer') icon.textContent = '#';
|
|
2818
|
+
else if (col.type === 'number') icon.textContent = '#.#';
|
|
2819
|
+
else if (constraints.pattern) icon.textContent = '/.*/';
|
|
2820
|
+
else icon.textContent = 'Aa';
|
|
2821
|
+
fieldDiv.appendChild(icon);
|
|
2822
|
+
|
|
2823
|
+
form.appendChild(fieldDiv);
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
body.appendChild(form);
|
|
2827
|
+
|
|
2828
|
+
// Actions
|
|
2829
|
+
var actions = document.createElement('div');
|
|
2830
|
+
actions.className = 'br-row-editor-actions';
|
|
2831
|
+
|
|
2832
|
+
var saveBtn = document.createElement('button');
|
|
2833
|
+
saveBtn.className = 'br-re-save';
|
|
2834
|
+
saveBtn.textContent = 'Save';
|
|
2835
|
+
saveBtn.disabled = true;
|
|
2836
|
+
|
|
2837
|
+
var cancelBtn = document.createElement('button');
|
|
2838
|
+
cancelBtn.className = 'br-re-cancel';
|
|
2839
|
+
cancelBtn.textContent = 'Cancel';
|
|
2840
|
+
|
|
2841
|
+
var reFieldErrors = {};
|
|
2842
|
+
|
|
2843
|
+
function reValidateField(inputEl, col) {
|
|
2844
|
+
var key = inputEl.getAttribute('data-col-key');
|
|
2845
|
+
var val = inputEl.value;
|
|
2846
|
+
// Convert date-pattern fields for validation
|
|
2847
|
+
var dp = inputEl.getAttribute('data-date-pattern');
|
|
2848
|
+
if (dp && val) val = fromIsoDate(val, dp === 'pad');
|
|
2849
|
+
var c = col.constraints || {};
|
|
2850
|
+
var isRequired = requiredFields.indexOf(col.key) >= 0;
|
|
2851
|
+
var error = null;
|
|
2852
|
+
|
|
2853
|
+
if (isRequired && (val === '' || val === null || val === undefined)) {
|
|
2854
|
+
error = col.title + ' is required';
|
|
2855
|
+
} else if (val !== '' && val !== null && val !== undefined) {
|
|
2856
|
+
if (col.type === 'integer') {
|
|
2857
|
+
if (!/^-?\d+$/.test(val)) error = col.title + ' must be an integer';
|
|
2858
|
+
else {
|
|
2859
|
+
var n = parseInt(val);
|
|
2860
|
+
if (c.minimum !== undefined && n < c.minimum) error = col.title + ' must be at least ' + c.minimum;
|
|
2861
|
+
if (c.maximum !== undefined && n > c.maximum) error = col.title + ' must be at most ' + c.maximum;
|
|
2862
|
+
}
|
|
2863
|
+
} else if (col.type === 'number') {
|
|
2864
|
+
if (isNaN(parseFloat(val))) error = col.title + ' must be a number';
|
|
2865
|
+
else {
|
|
2866
|
+
var n = parseFloat(val);
|
|
2867
|
+
if (c.minimum !== undefined && n < c.minimum) error = col.title + ' must be at least ' + c.minimum;
|
|
2868
|
+
if (c.maximum !== undefined && n > c.maximum) error = col.title + ' must be at most ' + c.maximum;
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
if (!error && c.enum && c.enum.indexOf(val) < 0) {
|
|
2872
|
+
error = col.title + ' must be one of: ' + c.enum.join(', ');
|
|
2873
|
+
}
|
|
2874
|
+
var isDatePat = !c.format && c.pattern &&
|
|
2875
|
+
/^\^?\\d\{[12](,2)?\}[\/\-]\\d\{[12](,2)?\}[\/\-]\\d\{[24]\}\$?$/.test(c.pattern);
|
|
2876
|
+
if (!error && c.pattern && !isDatePat) {
|
|
2877
|
+
try {
|
|
2878
|
+
var re = new RegExp(c.pattern);
|
|
2879
|
+
if (!re.test(val)) error = col.title + ' does not match expected format';
|
|
2880
|
+
} catch(e) {}
|
|
2881
|
+
}
|
|
2882
|
+
if (!error && c.format === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(val)) {
|
|
2883
|
+
error = col.title + ' must be a valid date (YYYY-MM-DD)';
|
|
2884
|
+
}
|
|
2885
|
+
if (!error && c.format === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
|
|
2886
|
+
error = col.title + ' must be a valid email';
|
|
2887
|
+
}
|
|
2888
|
+
if (!error && c.minLength !== undefined && val.length < c.minLength) {
|
|
2889
|
+
error = col.title + ' must be at least ' + c.minLength + ' characters';
|
|
2890
|
+
}
|
|
2891
|
+
if (!error && c.maxLength !== undefined && val.length > c.maxLength) {
|
|
2892
|
+
error = col.title + ' must be at most ' + c.maxLength + ' characters';
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
if (error) {
|
|
2897
|
+
reFieldErrors[key] = error;
|
|
2898
|
+
inputEl.classList.add('invalid');
|
|
2899
|
+
inputEl.title = error;
|
|
2900
|
+
} else {
|
|
2901
|
+
delete reFieldErrors[key];
|
|
2902
|
+
inputEl.classList.remove('invalid');
|
|
2903
|
+
inputEl.title = '';
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
function updateSaveBtn() {
|
|
2908
|
+
var hasDirty = form.querySelector('.dirty');
|
|
2909
|
+
var hasErrors = Object.keys(reFieldErrors).length > 0;
|
|
2910
|
+
saveBtn.disabled = !hasDirty && !hasErrors;
|
|
2911
|
+
if (hasErrors) {
|
|
2912
|
+
saveBtn.classList.add('has-errors');
|
|
2913
|
+
saveBtn.textContent = 'Show errors';
|
|
2914
|
+
} else if (hasDirty) {
|
|
2915
|
+
saveBtn.classList.remove('has-errors');
|
|
2916
|
+
saveBtn.textContent = 'Save';
|
|
2917
|
+
} else {
|
|
2918
|
+
saveBtn.classList.remove('has-errors');
|
|
2919
|
+
saveBtn.textContent = 'No changes';
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
function getChanges() {
|
|
2924
|
+
var changes = {};
|
|
2925
|
+
form.querySelectorAll('[data-col-key]').forEach(function(el) {
|
|
2926
|
+
var key = el.getAttribute('data-col-key');
|
|
2927
|
+
var curVal = el.value;
|
|
2928
|
+
if (curVal !== el.getAttribute('data-orig-val')) {
|
|
2929
|
+
// Convert date-pattern fields from ISO back to M/D/YYYY
|
|
2930
|
+
var dp = el.getAttribute('data-date-pattern');
|
|
2931
|
+
if (dp && curVal) {
|
|
2932
|
+
var d = curVal.split('-');
|
|
2933
|
+
var m = dp === 'pad' ? d[1] : String(parseInt(d[1]));
|
|
2934
|
+
var day = dp === 'pad' ? d[2] : String(parseInt(d[2]));
|
|
2935
|
+
curVal = m + '/' + day + '/' + d[0];
|
|
2936
|
+
}
|
|
2937
|
+
changes[key] = curVal;
|
|
2938
|
+
}
|
|
2939
|
+
});
|
|
2940
|
+
return changes;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
saveBtn.addEventListener('click', function() {
|
|
2944
|
+
// If there are validation errors, show them
|
|
2945
|
+
if (Object.keys(reFieldErrors).length > 0) {
|
|
2946
|
+
var errors = Object.keys(reFieldErrors).map(function(k) { return reFieldErrors[k]; });
|
|
2947
|
+
showValidationPopup(errors, saveBtn);
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
var changes = getChanges();
|
|
2952
|
+
if (Object.keys(changes).length === 0) return;
|
|
2953
|
+
saveBtn.disabled = true;
|
|
2954
|
+
saveBtn.textContent = 'Saving\u2026';
|
|
2955
|
+
|
|
2956
|
+
var saveUrl = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
2957
|
+
'/tables/' + encodeURIComponent(data.table) +
|
|
2958
|
+
'/rows/' + rowIdx;
|
|
2959
|
+
|
|
2960
|
+
fetch(saveUrl, {
|
|
2961
|
+
method: 'PUT',
|
|
2962
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2963
|
+
body: JSON.stringify({ changes: changes })
|
|
2964
|
+
})
|
|
2965
|
+
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, status: r.status, data: j }; }); })
|
|
2966
|
+
.then(function(res) {
|
|
2967
|
+
if (res.status === 403) {
|
|
2968
|
+
showAdminRequired(res.data);
|
|
2969
|
+
saveBtn.disabled = false;
|
|
2970
|
+
saveBtn.textContent = 'Save';
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (res.ok && res.data.valid) {
|
|
2974
|
+
// Update the underlying table data
|
|
2975
|
+
var origTableRow = originalRows.find(function(r) { return r[0] === rowIdx; });
|
|
2976
|
+
if (origTableRow) {
|
|
2977
|
+
Object.keys(changes).forEach(function(colKey) {
|
|
2978
|
+
var ci = columns.findIndex(function(c) { return c.key === colKey; });
|
|
2979
|
+
if (ci >= 0) {
|
|
2980
|
+
var coerced = changes[colKey];
|
|
2981
|
+
var colType = columns[ci].type;
|
|
2982
|
+
if (colType === 'integer') coerced = parseInt(coerced) || coerced;
|
|
2983
|
+
else if (colType === 'number') coerced = parseFloat(coerced) || coerced;
|
|
2984
|
+
origTableRow[ci + 1] = coerced;
|
|
2985
|
+
}
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
clearRowValidation(rowIdx);
|
|
2989
|
+
render();
|
|
2990
|
+
editorPopup.remove();
|
|
2991
|
+
} else {
|
|
2992
|
+
var rawErrors = res.data.errors || [{message: 'Validation failed'}];
|
|
2993
|
+
var msg = rawErrors.map(function(e) { return e.message || e; }).join('\n');
|
|
2994
|
+
alert('Validation error:\n' + msg);
|
|
2995
|
+
saveBtn.disabled = false;
|
|
2996
|
+
saveBtn.textContent = 'Save';
|
|
2997
|
+
}
|
|
2998
|
+
})
|
|
2999
|
+
.catch(function(err) {
|
|
3000
|
+
alert('Save failed: ' + err.message);
|
|
3001
|
+
saveBtn.disabled = false;
|
|
3002
|
+
saveBtn.textContent = 'Save';
|
|
3003
|
+
});
|
|
3004
|
+
});
|
|
3005
|
+
|
|
3006
|
+
cancelBtn.addEventListener('click', function() {
|
|
3007
|
+
editorPopup.remove();
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
if (!isReadonly) {
|
|
3011
|
+
actions.appendChild(cancelBtn);
|
|
3012
|
+
actions.appendChild(saveBtn);
|
|
3013
|
+
body.appendChild(actions);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
// Upfront validation: run client-side checks on all fields
|
|
3017
|
+
form.querySelectorAll('[data-col-key]').forEach(function(el) {
|
|
3018
|
+
var key = el.getAttribute('data-col-key');
|
|
3019
|
+
var col = allCols.find(function(c) { return c.key === key; });
|
|
3020
|
+
if (col) reValidateField(el, col);
|
|
3021
|
+
});
|
|
3022
|
+
updateSaveBtn();
|
|
3023
|
+
|
|
3024
|
+
// Server-side validation for multi-field rules (if/then conditions)
|
|
3025
|
+
if (dbKey && dbKey !== '_unmapped') {
|
|
3026
|
+
var valUrl = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
3027
|
+
'/tables/' + encodeURIComponent(data.table) +
|
|
3028
|
+
'/rows/' + rowIdx + '/validate';
|
|
3029
|
+
fetch(valUrl)
|
|
3030
|
+
.then(function(r) { return r.json(); })
|
|
3031
|
+
.then(function(result) {
|
|
3032
|
+
if (!result.valid && result.errors) {
|
|
3033
|
+
result.errors.forEach(function(err) {
|
|
3034
|
+
var msg = err.message || err;
|
|
3035
|
+
var fields = err.fields || [];
|
|
3036
|
+
fields.forEach(function(f) {
|
|
3037
|
+
if (reFieldErrors[f]) return; // client-side already caught it
|
|
3038
|
+
reFieldErrors[f] = msg;
|
|
3039
|
+
var el = form.querySelector('[data-col-key="' + f + '"]');
|
|
3040
|
+
if (el) {
|
|
3041
|
+
el.classList.add('invalid');
|
|
3042
|
+
el.title = msg;
|
|
3043
|
+
}
|
|
3044
|
+
});
|
|
3045
|
+
});
|
|
3046
|
+
updateSaveBtn();
|
|
3047
|
+
}
|
|
3048
|
+
})
|
|
3049
|
+
.catch(function() {}); // silently ignore if server validation fails
|
|
3050
|
+
}
|
|
3051
|
+
})
|
|
3052
|
+
.catch(function(err) { console.error('Failed to open row editor:', err); });
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
modeToggle.addEventListener('click', function() {
|
|
3056
|
+
editMode = !editMode;
|
|
3057
|
+
if (editMode) {
|
|
3058
|
+
modeToggle.textContent = '\u270e';
|
|
3059
|
+
modeToggle.title = 'Edit mode \u2014 click to switch to view mode';
|
|
3060
|
+
modeToggle.classList.add('edit-mode');
|
|
3061
|
+
} else {
|
|
3062
|
+
modeToggle.textContent = '\ud83d\udcd6';
|
|
3063
|
+
modeToggle.title = 'View mode \u2014 click to switch to edit mode';
|
|
3064
|
+
modeToggle.classList.remove('edit-mode');
|
|
3065
|
+
}
|
|
3066
|
+
render();
|
|
3067
|
+
});
|
|
3068
|
+
|
|
3069
|
+
validateBtn.addEventListener('click', function() {
|
|
3070
|
+
if (!dbKey || dbKey === '_unmapped') return;
|
|
3071
|
+
validateBtn.disabled = true;
|
|
3072
|
+
validateBtn.title = 'Validating\u2026';
|
|
3073
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
3074
|
+
'/tables/' + encodeURIComponent(data.table) + '/validate';
|
|
3075
|
+
fetch(url)
|
|
3076
|
+
.then(function(r) { return r.json(); })
|
|
3077
|
+
.then(function(result) {
|
|
3078
|
+
validationErrors = {};
|
|
3079
|
+
// Clear previous server-sourced cell errors
|
|
3080
|
+
serverCellErrorKeys.forEach(function(k) { delete cellValidationErrors[k]; });
|
|
3081
|
+
serverCellErrorKeys = [];
|
|
3082
|
+
var errs = result.errors || {};
|
|
3083
|
+
Object.keys(errs).forEach(function(k) {
|
|
3084
|
+
var rowIdx = parseInt(k);
|
|
3085
|
+
validationErrors[rowIdx] = errs[k];
|
|
3086
|
+
// Populate cell-level errors from server field info
|
|
3087
|
+
errs[k].forEach(function(err) {
|
|
3088
|
+
var msg = err.message || err;
|
|
3089
|
+
var fields = err.fields || [];
|
|
3090
|
+
fields.forEach(function(f) {
|
|
3091
|
+
var errKey = rowIdx + ':' + f;
|
|
3092
|
+
cellValidationErrors[errKey] = msg;
|
|
3093
|
+
serverCellErrorKeys.push(errKey);
|
|
3094
|
+
});
|
|
3095
|
+
});
|
|
3096
|
+
});
|
|
3097
|
+
hasValidation = Object.keys(validationErrors).length > 0;
|
|
3098
|
+
validateBtn.disabled = false;
|
|
3099
|
+
updateValidateBtn();
|
|
3100
|
+
render();
|
|
3101
|
+
})
|
|
3102
|
+
.catch(function() {
|
|
3103
|
+
validateBtn.disabled = false;
|
|
3104
|
+
validateBtn.title = 'Validation failed';
|
|
3105
|
+
});
|
|
3106
|
+
});
|
|
3107
|
+
|
|
3108
|
+
tableWrap.addEventListener('click', function(e) {
|
|
3109
|
+
var valIcon = e.target.closest('[data-val-row]');
|
|
3110
|
+
if (!valIcon) return;
|
|
3111
|
+
var rowIdx = parseInt(valIcon.getAttribute('data-val-row'));
|
|
3112
|
+
var errors = validationErrors[rowIdx];
|
|
3113
|
+
if (!errors || errors.length === 0) return;
|
|
3114
|
+
var pos = nextPosition();
|
|
3115
|
+
var errHtml = '<ul style="margin:0;padding:0 0 0 1.2em;font-size:0.85rem;line-height:1.6">';
|
|
3116
|
+
errors.forEach(function(err) { errHtml += '<li>' + escHtml(err.message || err) + '</li>'; });
|
|
3117
|
+
errHtml += '</ul>';
|
|
3118
|
+
createPopup({ title: 'Row ' + (rowIdx + 1) + ' \u2014 validation errors', html: errHtml, x: pos.x, y: pos.y });
|
|
3119
|
+
});
|
|
3120
|
+
|
|
3121
|
+
rowNumToggle.addEventListener('click', function() {
|
|
3122
|
+
showRowNumbers = !showRowNumbers;
|
|
3123
|
+
rowNumToggle.classList.toggle('active', showRowNumbers);
|
|
3124
|
+
renderTableHTML();
|
|
3125
|
+
});
|
|
3126
|
+
|
|
3127
|
+
saveDefaultBtn.addEventListener('click', function() {
|
|
3128
|
+
var tableKey = data.table || data.table_key;
|
|
3129
|
+
var viewKey = data.view || data.view_key || 'all';
|
|
3130
|
+
var extra = {};
|
|
3131
|
+
if (Object.keys(colWidths).length > 0) extra.colWidths = colWidths;
|
|
3132
|
+
if (searchInput.value) extra.searchQuery = searchInput.value;
|
|
3133
|
+
var nonEmptyColFilters = {};
|
|
3134
|
+
Object.keys(colFilterValues).forEach(function(k) {
|
|
3135
|
+
if (colFilterValues[k]) nonEmptyColFilters[k] = colFilterValues[k];
|
|
3136
|
+
});
|
|
3137
|
+
if (Object.keys(nonEmptyColFilters).length > 0) extra.colFilterValues = nonEmptyColFilters;
|
|
3138
|
+
if (colFiltersVisible) extra.colFiltersVisible = true;
|
|
3139
|
+
saveDefaultViewKey(dbKey, tableKey, viewKey, extra);
|
|
3140
|
+
saveDefaultBtn.classList.add('active');
|
|
3141
|
+
saveDefaultBtn.title = 'Saved as default view';
|
|
3142
|
+
});
|
|
3143
|
+
|
|
3144
|
+
saveDefaultBtn.addEventListener('contextmenu', function(e) {
|
|
3145
|
+
e.preventDefault();
|
|
3146
|
+
e.stopPropagation();
|
|
3147
|
+
var tableKey = data.table || data.table_key;
|
|
3148
|
+
var lsKey = 'markdownr:defaultView:' + dbKey + ':' + tableKey;
|
|
3149
|
+
var saved = getSavedDefaultView(dbKey, tableKey);
|
|
3150
|
+
var pos = nextPosition();
|
|
3151
|
+
var p = createPopup({ title: tableKey + ' \u2014 saved default view', x: pos.x, y: pos.y });
|
|
3152
|
+
var body = p.querySelector('.br-popup-body');
|
|
3153
|
+
if (saved) {
|
|
3154
|
+
var yaml = toYaml(saved);
|
|
3155
|
+
body.innerHTML = '<pre style="margin:0;padding:0.75rem;font-size:0.8rem;white-space:pre-wrap;font-family:monospace;">' + escHtml(yaml) + '</pre>' +
|
|
3156
|
+
'<div style="padding:0.5rem 0.75rem;border-top:1px solid #e8e4dc;"><button class="br-ls-delete" style="background:#c0392b;color:#fff;border:none;padding:0.3rem 0.8rem;border-radius:3px;cursor:pointer;font-size:0.8rem;">Delete</button></div>';
|
|
3157
|
+
body.querySelector('.br-ls-delete').addEventListener('click', function() {
|
|
3158
|
+
localStorage.removeItem(lsKey);
|
|
3159
|
+
saveDefaultBtn.classList.remove('active');
|
|
3160
|
+
saveDefaultBtn.classList.remove('loaded');
|
|
3161
|
+
saveDefaultBtn.title = 'Save as default view';
|
|
3162
|
+
colWidths = {};
|
|
3163
|
+
renderTableHTML();
|
|
3164
|
+
p.remove();
|
|
3165
|
+
});
|
|
3166
|
+
} else {
|
|
3167
|
+
body.innerHTML = '<div style="padding:0.75rem;font-size:0.85rem;color:#888;">No saved default view for this table.</div>';
|
|
3168
|
+
}
|
|
3169
|
+
});
|
|
3170
|
+
|
|
3171
|
+
rowNumToggle.addEventListener('contextmenu', function(e) {
|
|
3172
|
+
e.preventDefault();
|
|
3173
|
+
e.stopPropagation();
|
|
3174
|
+
var all = {};
|
|
3175
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
3176
|
+
var k = localStorage.key(i);
|
|
3177
|
+
if (k.indexOf('markdownr:') !== 0) continue;
|
|
3178
|
+
try { all[k] = JSON.parse(localStorage.getItem(k)); }
|
|
3179
|
+
catch(ex) { all[k] = localStorage.getItem(k); }
|
|
3180
|
+
}
|
|
3181
|
+
var yaml = Object.keys(all).length > 0 ? toYaml(all) : '# No markdownr localStorage entries';
|
|
3182
|
+
var pos = nextPosition();
|
|
3183
|
+
var p = createPopup({ title: 'All markdownr localStorage', x: pos.x, y: pos.y, wide: true });
|
|
3184
|
+
p.querySelector('.br-popup-body').innerHTML = '<pre style="margin:0;padding:0.75rem;font-size:0.8rem;white-space:pre-wrap;font-family:monospace;">' + escHtml(yaml) + '</pre>';
|
|
3185
|
+
});
|
|
3186
|
+
|
|
3187
|
+
colFilterToggle.addEventListener('click', function() {
|
|
3188
|
+
colFiltersVisible = !colFiltersVisible;
|
|
3189
|
+
colFilterToggle.textContent = colFiltersVisible ? '\u25b4' : '\u25be';
|
|
3190
|
+
render();
|
|
3191
|
+
if (colFiltersVisible) {
|
|
3192
|
+
var first = tableWrap.querySelector('.br-col-filter-row input');
|
|
3193
|
+
if (first) first.focus();
|
|
3194
|
+
}
|
|
3195
|
+
});
|
|
3196
|
+
|
|
3197
|
+
tableWrap.addEventListener('input', function(e) {
|
|
3198
|
+
var filterInput = e.target.closest('[data-col-filter]');
|
|
3199
|
+
if (!filterInput) return;
|
|
3200
|
+
colFilterValues[filterInput.getAttribute('data-col-filter')] = filterInput.value;
|
|
3201
|
+
render();
|
|
3202
|
+
// Re-focus the input after render rebuilds the DOM
|
|
3203
|
+
var restored = tableWrap.querySelector('[data-col-filter="' + filterInput.getAttribute('data-col-filter') + '"]');
|
|
3204
|
+
if (restored) { restored.focus(); restored.selectionStart = restored.selectionEnd = restored.value.length; }
|
|
3205
|
+
});
|
|
3206
|
+
|
|
3207
|
+
var highlightDropdown = null;
|
|
3208
|
+
|
|
3209
|
+
function dismissHighlightDropdown() {
|
|
3210
|
+
if (highlightDropdown) { highlightDropdown.remove(); highlightDropdown = null; }
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
function showHighlightDropdown() {
|
|
3214
|
+
dismissHighlightDropdown();
|
|
3215
|
+
var rect = searchInput.getBoundingClientRect();
|
|
3216
|
+
var dd = document.createElement('div');
|
|
3217
|
+
dd.className = 'br-highlight-dropdown';
|
|
3218
|
+
dd.style.left = rect.left + 'px';
|
|
3219
|
+
dd.style.top = (rect.bottom + 2) + 'px';
|
|
3220
|
+
dd.style.minWidth = rect.width + 'px';
|
|
3221
|
+
|
|
3222
|
+
var items = [
|
|
3223
|
+
{ key: 'cells', label: 'Highlight cells' },
|
|
3224
|
+
{ key: 'rows', label: 'Highlight rows' }
|
|
3225
|
+
];
|
|
3226
|
+
items.forEach(function(item) {
|
|
3227
|
+
var btn = document.createElement('button');
|
|
3228
|
+
btn.className = 'br-highlight-dropdown-item' + (highlightMode === item.key ? ' active' : '');
|
|
3229
|
+
btn.textContent = item.label;
|
|
3230
|
+
btn.addEventListener('mousedown', function(e) {
|
|
3231
|
+
e.preventDefault(); // keep focus on searchInput
|
|
3232
|
+
highlightMode = item.key;
|
|
3233
|
+
// If input is just "!", add the mode prefix
|
|
3234
|
+
var val = searchInput.value;
|
|
3235
|
+
var afterBang = val.slice(1).trimStart();
|
|
3236
|
+
searchInput.value = '!' + item.key + ' ' + afterBang;
|
|
3237
|
+
searchInput.selectionStart = searchInput.selectionEnd = searchInput.value.length;
|
|
3238
|
+
dismissHighlightDropdown();
|
|
3239
|
+
parseHighlightInput(searchInput.value);
|
|
3240
|
+
render();
|
|
3241
|
+
});
|
|
3242
|
+
dd.appendChild(btn);
|
|
3243
|
+
});
|
|
3244
|
+
|
|
3245
|
+
document.body.appendChild(dd);
|
|
3246
|
+
highlightDropdown = dd;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
function parseHighlightInput(val) {
|
|
3250
|
+
if (val.charAt(0) !== '!') {
|
|
3251
|
+
highlightMode = null;
|
|
3252
|
+
highlightTerms = [];
|
|
3253
|
+
searchTerms = parseFilterTerms(val);
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
// Parse "!mode terms..."
|
|
3257
|
+
var rest = val.slice(1).trimStart();
|
|
3258
|
+
var match = rest.match(/^(cells|rows)\b\s*(.*)/);
|
|
3259
|
+
if (match) {
|
|
3260
|
+
highlightMode = match[1];
|
|
3261
|
+
highlightTerms = parseFilterTerms(match[2]);
|
|
3262
|
+
searchTerms = [];
|
|
3263
|
+
} else {
|
|
3264
|
+
// "!" typed but no mode selected yet — no filtering, no highlighting
|
|
3265
|
+
highlightMode = null;
|
|
3266
|
+
highlightTerms = [];
|
|
3267
|
+
searchTerms = [];
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
searchInput.addEventListener('input', function() {
|
|
3272
|
+
var val = searchInput.value;
|
|
3273
|
+
if (val.charAt(0) === '!') {
|
|
3274
|
+
var rest = val.slice(1).trimStart();
|
|
3275
|
+
// Show dropdown if no mode word yet
|
|
3276
|
+
if (!/^(cells|rows)\b/.test(rest)) {
|
|
3277
|
+
showHighlightDropdown();
|
|
3278
|
+
} else {
|
|
3279
|
+
dismissHighlightDropdown();
|
|
3280
|
+
}
|
|
3281
|
+
parseHighlightInput(val);
|
|
3282
|
+
} else {
|
|
3283
|
+
dismissHighlightDropdown();
|
|
3284
|
+
highlightMode = null;
|
|
3285
|
+
highlightTerms = [];
|
|
3286
|
+
searchTerms = parseFilterTerms(val);
|
|
3287
|
+
}
|
|
3288
|
+
render();
|
|
3289
|
+
});
|
|
3290
|
+
|
|
3291
|
+
searchInput.addEventListener('blur', function() {
|
|
3292
|
+
// Small delay to allow dropdown mousedown to fire first
|
|
3293
|
+
setTimeout(dismissHighlightDropdown, 150);
|
|
3294
|
+
});
|
|
3295
|
+
|
|
3296
|
+
searchInput.addEventListener('keydown', function(e) {
|
|
3297
|
+
if (e.key === 'Escape' && highlightDropdown) {
|
|
3298
|
+
dismissHighlightDropdown();
|
|
3299
|
+
e.stopPropagation();
|
|
3300
|
+
}
|
|
3301
|
+
});
|
|
3302
|
+
|
|
3303
|
+
if (initOpts.searchQuery) {
|
|
3304
|
+
searchInput.value = initOpts.searchQuery;
|
|
3305
|
+
parseHighlightInput(searchInput.value);
|
|
3306
|
+
render();
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// ── Context menu ──
|
|
3310
|
+
|
|
3311
|
+
function clearMatchHighlights() {
|
|
3312
|
+
tableWrap.querySelectorAll('.br-cell-match').forEach(function(el) { el.classList.remove('br-cell-match'); });
|
|
3313
|
+
tableWrap.querySelectorAll('.br-row-match').forEach(function(el) { el.classList.remove('br-row-match'); });
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
tableWrap.addEventListener('contextmenu', function(e) {
|
|
3317
|
+
var tr = e.target.closest('tr[data-row-idx]');
|
|
3318
|
+
if (!tr) return;
|
|
3319
|
+
var td = e.target.closest('td[data-col]');
|
|
3320
|
+
e.preventDefault();
|
|
3321
|
+
dismissContextMenu();
|
|
3322
|
+
|
|
3323
|
+
var rowIdx = parseInt(tr.getAttribute('data-row-idx'));
|
|
3324
|
+
var menu = document.createElement('div');
|
|
3325
|
+
menu.className = 'br-context-menu';
|
|
3326
|
+
menu.style.left = e.clientX + 'px';
|
|
3327
|
+
menu.style.top = e.clientY + 'px';
|
|
3328
|
+
|
|
3329
|
+
// View-mode matching options (only when right-clicking a data cell)
|
|
3330
|
+
if (!editMode && td) {
|
|
3331
|
+
var colIdx = parseInt(td.getAttribute('data-col'));
|
|
3332
|
+
var row = displayRows.find(function(r) { return r[0] === rowIdx; });
|
|
3333
|
+
var cellVal = row ? getCellValue(row, colIdx) : '';
|
|
3334
|
+
|
|
3335
|
+
// Copy
|
|
3336
|
+
var copyBtn = document.createElement('button');
|
|
3337
|
+
copyBtn.className = 'br-context-menu-item default';
|
|
3338
|
+
copyBtn.textContent = 'Copy';
|
|
3339
|
+
copyBtn.addEventListener('click', function() {
|
|
3340
|
+
dismissContextMenu();
|
|
3341
|
+
navigator.clipboard.writeText(cellVal);
|
|
3342
|
+
});
|
|
3343
|
+
menu.appendChild(copyBtn);
|
|
3344
|
+
|
|
3345
|
+
// Set as table filter
|
|
3346
|
+
var setTableFilterBtn = document.createElement('button');
|
|
3347
|
+
setTableFilterBtn.className = 'br-context-menu-item default';
|
|
3348
|
+
setTableFilterBtn.textContent = 'Set as table filter';
|
|
3349
|
+
setTableFilterBtn.addEventListener('click', function() {
|
|
3350
|
+
dismissContextMenu();
|
|
3351
|
+
searchInput.value = cellVal;
|
|
3352
|
+
searchInput.dispatchEvent(new Event('input'));
|
|
3353
|
+
searchInput.focus();
|
|
3354
|
+
});
|
|
3355
|
+
menu.appendChild(setTableFilterBtn);
|
|
3356
|
+
|
|
3357
|
+
// Set as column filter
|
|
3358
|
+
var setColFilterBtn = document.createElement('button');
|
|
3359
|
+
setColFilterBtn.className = 'br-context-menu-item default';
|
|
3360
|
+
setColFilterBtn.textContent = 'Set as column filter';
|
|
3361
|
+
setColFilterBtn.addEventListener('click', function() {
|
|
3362
|
+
dismissContextMenu();
|
|
3363
|
+
var colKey = columns[colIdx].key;
|
|
3364
|
+
colFilterValues[colKey] = cellVal;
|
|
3365
|
+
if (!colFiltersVisible) {
|
|
3366
|
+
colFiltersVisible = true;
|
|
3367
|
+
colFilterToggle.textContent = '\u25b4';
|
|
3368
|
+
}
|
|
3369
|
+
render();
|
|
3370
|
+
var input = tableWrap.querySelector('[data-col-filter="' + colKey + '"]');
|
|
3371
|
+
if (input) { input.focus(); input.selectionStart = input.selectionEnd = input.value.length; }
|
|
3372
|
+
});
|
|
3373
|
+
menu.appendChild(setColFilterBtn);
|
|
3374
|
+
|
|
3375
|
+
var sep1 = document.createElement('div');
|
|
3376
|
+
sep1.className = 'br-context-menu-sep';
|
|
3377
|
+
menu.appendChild(sep1);
|
|
3378
|
+
|
|
3379
|
+
// Match this column
|
|
3380
|
+
var matchColBtn = document.createElement('button');
|
|
3381
|
+
matchColBtn.className = 'br-context-menu-item default';
|
|
3382
|
+
matchColBtn.textContent = 'Match this column';
|
|
3383
|
+
matchColBtn.addEventListener('click', function() {
|
|
3384
|
+
dismissContextMenu();
|
|
3385
|
+
clearMatchHighlights();
|
|
3386
|
+
tableWrap.querySelectorAll('td[data-col="' + colIdx + '"]').forEach(function(cell) {
|
|
3387
|
+
var cellTr = cell.closest('tr[data-row-idx]');
|
|
3388
|
+
if (!cellTr) return;
|
|
3389
|
+
var ri = parseInt(cellTr.getAttribute('data-row-idx'));
|
|
3390
|
+
var r = displayRows.find(function(dr) { return dr[0] === ri; });
|
|
3391
|
+
if (r && getCellValue(r, colIdx) === cellVal) cell.classList.add('br-cell-match');
|
|
3392
|
+
});
|
|
3393
|
+
});
|
|
3394
|
+
menu.appendChild(matchColBtn);
|
|
3395
|
+
|
|
3396
|
+
// Match (any column)
|
|
3397
|
+
var matchAnyBtn = document.createElement('button');
|
|
3398
|
+
matchAnyBtn.className = 'br-context-menu-item default';
|
|
3399
|
+
matchAnyBtn.textContent = 'Match';
|
|
3400
|
+
matchAnyBtn.addEventListener('click', function() {
|
|
3401
|
+
dismissContextMenu();
|
|
3402
|
+
clearMatchHighlights();
|
|
3403
|
+
tableWrap.querySelectorAll('tr[data-row-idx]').forEach(function(row) {
|
|
3404
|
+
var ri = parseInt(row.getAttribute('data-row-idx'));
|
|
3405
|
+
var r = displayRows.find(function(dr) { return dr[0] === ri; });
|
|
3406
|
+
if (!r) return;
|
|
3407
|
+
row.querySelectorAll('td[data-col]').forEach(function(cell) {
|
|
3408
|
+
var ci = parseInt(cell.getAttribute('data-col'));
|
|
3409
|
+
if (getCellValue(r, ci) === cellVal) cell.classList.add('br-cell-match');
|
|
3410
|
+
});
|
|
3411
|
+
});
|
|
3412
|
+
});
|
|
3413
|
+
menu.appendChild(matchAnyBtn);
|
|
3414
|
+
|
|
3415
|
+
// Match rows, this column
|
|
3416
|
+
var matchRowsColBtn = document.createElement('button');
|
|
3417
|
+
matchRowsColBtn.className = 'br-context-menu-item default';
|
|
3418
|
+
matchRowsColBtn.textContent = 'Match rows, this column';
|
|
3419
|
+
matchRowsColBtn.addEventListener('click', function() {
|
|
3420
|
+
dismissContextMenu();
|
|
3421
|
+
clearMatchHighlights();
|
|
3422
|
+
tableWrap.querySelectorAll('tr[data-row-idx]').forEach(function(row) {
|
|
3423
|
+
var ri = parseInt(row.getAttribute('data-row-idx'));
|
|
3424
|
+
var r = displayRows.find(function(dr) { return dr[0] === ri; });
|
|
3425
|
+
if (r && getCellValue(r, colIdx) === cellVal) row.classList.add('br-row-match');
|
|
3426
|
+
});
|
|
3427
|
+
});
|
|
3428
|
+
menu.appendChild(matchRowsColBtn);
|
|
3429
|
+
|
|
3430
|
+
// Match rows (any column)
|
|
3431
|
+
var matchRowsBtn = document.createElement('button');
|
|
3432
|
+
matchRowsBtn.className = 'br-context-menu-item default';
|
|
3433
|
+
matchRowsBtn.textContent = 'Match rows';
|
|
3434
|
+
matchRowsBtn.addEventListener('click', function() {
|
|
3435
|
+
dismissContextMenu();
|
|
3436
|
+
clearMatchHighlights();
|
|
3437
|
+
tableWrap.querySelectorAll('tr[data-row-idx]').forEach(function(row) {
|
|
3438
|
+
var ri = parseInt(row.getAttribute('data-row-idx'));
|
|
3439
|
+
var r = displayRows.find(function(dr) { return dr[0] === ri; });
|
|
3440
|
+
if (!r) return;
|
|
3441
|
+
for (var ci = 0; ci < columns.length; ci++) {
|
|
3442
|
+
if (getCellValue(r, ci) === cellVal) { row.classList.add('br-row-match'); break; }
|
|
3443
|
+
}
|
|
3444
|
+
});
|
|
3445
|
+
});
|
|
3446
|
+
menu.appendChild(matchRowsBtn);
|
|
3447
|
+
|
|
3448
|
+
// Clear matches
|
|
3449
|
+
var sepClear = document.createElement('div');
|
|
3450
|
+
sepClear.className = 'br-context-menu-sep';
|
|
3451
|
+
menu.appendChild(sepClear);
|
|
3452
|
+
|
|
3453
|
+
var clearBtn = document.createElement('button');
|
|
3454
|
+
clearBtn.className = 'br-context-menu-item default';
|
|
3455
|
+
clearBtn.textContent = 'Clear highlights';
|
|
3456
|
+
clearBtn.addEventListener('click', function() {
|
|
3457
|
+
dismissContextMenu();
|
|
3458
|
+
clearMatchHighlights();
|
|
3459
|
+
});
|
|
3460
|
+
menu.appendChild(clearBtn);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
// Duplicate / Delete record (always available)
|
|
3464
|
+
if (menu.children.length > 0) {
|
|
3465
|
+
var sepDel = document.createElement('div');
|
|
3466
|
+
sepDel.className = 'br-context-menu-sep';
|
|
3467
|
+
menu.appendChild(sepDel);
|
|
3468
|
+
}
|
|
3469
|
+
var duplicateBtn = document.createElement('button');
|
|
3470
|
+
duplicateBtn.className = 'br-context-menu-item default';
|
|
3471
|
+
duplicateBtn.textContent = 'Duplicate record';
|
|
3472
|
+
duplicateBtn.addEventListener('click', function() {
|
|
3473
|
+
dismissContextMenu();
|
|
3474
|
+
duplicateRow(dbKey, data.table, rowIdx);
|
|
3475
|
+
});
|
|
3476
|
+
menu.appendChild(duplicateBtn);
|
|
3477
|
+
|
|
3478
|
+
var deleteBtn = document.createElement('button');
|
|
3479
|
+
deleteBtn.className = 'br-context-menu-item';
|
|
3480
|
+
deleteBtn.textContent = 'Delete record';
|
|
3481
|
+
deleteBtn.addEventListener('click', function() {
|
|
3482
|
+
dismissContextMenu();
|
|
3483
|
+
deleteRow(dbKey, data.table, rowIdx);
|
|
3484
|
+
});
|
|
3485
|
+
menu.appendChild(deleteBtn);
|
|
3486
|
+
document.body.appendChild(menu);
|
|
3487
|
+
|
|
3488
|
+
// Async: fetch add-on actions for this row and append them to the menu.
|
|
3489
|
+
if (dbKey && dbKey !== '_unmapped' && data.table) {
|
|
3490
|
+
var addonsUrl = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
3491
|
+
'/tables/' + encodeURIComponent(data.table) +
|
|
3492
|
+
'/addons?row=' + rowIdx;
|
|
3493
|
+
fetch(addonsUrl)
|
|
3494
|
+
.then(function(r) { return r.ok ? r.json() : { actions: [] }; })
|
|
3495
|
+
.then(function(res) {
|
|
3496
|
+
if (!res.actions || res.actions.length === 0) return;
|
|
3497
|
+
if (!menu.isConnected) return; // menu was dismissed before fetch returned
|
|
3498
|
+
var sepAddon = document.createElement('div');
|
|
3499
|
+
sepAddon.className = 'br-context-menu-sep';
|
|
3500
|
+
menu.appendChild(sepAddon);
|
|
3501
|
+
res.actions.forEach(function(a) {
|
|
3502
|
+
var btn = document.createElement('button');
|
|
3503
|
+
btn.className = 'br-context-menu-item default';
|
|
3504
|
+
btn.textContent = (a.icon ? (a.icon + ' ') : '') + a.label;
|
|
3505
|
+
if (!a.enabled) { btn.disabled = true; btn.style.opacity = '0.5'; }
|
|
3506
|
+
btn.addEventListener('click', function() {
|
|
3507
|
+
dismissContextMenu();
|
|
3508
|
+
runAddonAction(dbKey, data.table, rowIdx, a.addon, a.id, null, null, data.color);
|
|
3509
|
+
});
|
|
3510
|
+
menu.appendChild(btn);
|
|
3511
|
+
});
|
|
3512
|
+
// Re-measure after items added
|
|
3513
|
+
var r2 = menu.getBoundingClientRect();
|
|
3514
|
+
if (r2.bottom > window.innerHeight) {
|
|
3515
|
+
menu.style.top = Math.max(4, (window.innerHeight - r2.height - 4)) + 'px';
|
|
3516
|
+
}
|
|
3517
|
+
})
|
|
3518
|
+
.catch(function() {});
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
// Keep menu within viewport
|
|
3522
|
+
var rect = menu.getBoundingClientRect();
|
|
3523
|
+
if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
|
|
3524
|
+
if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
function runAddonAction(dbKey, tableKey, rowIdx, addon, action, input, state, color) {
|
|
3528
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
3529
|
+
'/tables/' + encodeURIComponent(tableKey) +
|
|
3530
|
+
'/addons/' + encodeURIComponent(addon) +
|
|
3531
|
+
'/' + encodeURIComponent(action);
|
|
3532
|
+
fetch(url, {
|
|
3533
|
+
method: 'POST',
|
|
3534
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3535
|
+
body: JSON.stringify({ row_index: rowIdx, input: input, state: state })
|
|
3536
|
+
})
|
|
3537
|
+
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, status: r.status, data: j }; }); })
|
|
3538
|
+
.then(function(res) {
|
|
3539
|
+
if (res.status === 403) { showAdminRequired(res.data); return; }
|
|
3540
|
+
if (!res.ok) { alert('Error: ' + (res.data.error || 'request failed')); return; }
|
|
3541
|
+
var d = res.data;
|
|
3542
|
+
if (d.kind === 'prompt') {
|
|
3543
|
+
openAddonPromptDialog(d, function(values) {
|
|
3544
|
+
runAddonAction(dbKey, tableKey, rowIdx, addon, action, values, d.state, color);
|
|
3545
|
+
}, color);
|
|
3546
|
+
} else if (d.kind === 'error') {
|
|
3547
|
+
alert('Error: ' + (d.message || 'unknown'));
|
|
3548
|
+
} else {
|
|
3549
|
+
// done (or anything else) — refresh
|
|
3550
|
+
if (d.reload === false) return;
|
|
3551
|
+
if (Array.isArray(d.reload)) {
|
|
3552
|
+
// Patch specific rows — simplest: full refresh until we need finer control
|
|
3553
|
+
reloadTableData();
|
|
3554
|
+
} else {
|
|
3555
|
+
reloadTableData();
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
})
|
|
3559
|
+
.catch(function(err) { alert('Add-on call failed: ' + err.message); });
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
function reloadTableData() {
|
|
3563
|
+
var viewKey = data.view || data.view_key || 'all';
|
|
3564
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
3565
|
+
'/tables/' + encodeURIComponent(data.table) + '?view=' + encodeURIComponent(viewKey);
|
|
3566
|
+
fetch(url)
|
|
3567
|
+
.then(function(r) { return r.json(); })
|
|
3568
|
+
.then(function(fresh) {
|
|
3569
|
+
if (!fresh || !fresh.rows) return;
|
|
3570
|
+
originalRows.length = 0;
|
|
3571
|
+
fresh.rows.forEach(function(r) { originalRows.push(r); });
|
|
3572
|
+
dirtyRows = {};
|
|
3573
|
+
selectedRowIdx = null;
|
|
3574
|
+
render();
|
|
3575
|
+
})
|
|
3576
|
+
.catch(function() {});
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
function openAddonPromptDialog(prompt, onSubmit, color) {
|
|
3580
|
+
var pos = nextPosition();
|
|
3581
|
+
var popupId = 'addon-prompt:' + dbKey + ':' + data.table + ':' + Date.now();
|
|
3582
|
+
var dialog = createPopup({
|
|
3583
|
+
id: popupId, title: prompt.title || 'Add-on', pinned: true,
|
|
3584
|
+
x: pos.x, y: pos.y, width: 480, color: color
|
|
3585
|
+
});
|
|
3586
|
+
|
|
3587
|
+
var body = dialog.querySelector('.br-popup-body');
|
|
3588
|
+
body.style.padding = '0';
|
|
3589
|
+
body.style.overflowY = 'auto';
|
|
3590
|
+
|
|
3591
|
+
var form = document.createElement('div');
|
|
3592
|
+
form.className = 'br-row-editor';
|
|
3593
|
+
var fieldEls = {};
|
|
3594
|
+
|
|
3595
|
+
(prompt.fields || []).forEach(function(field) {
|
|
3596
|
+
var fieldDiv = document.createElement('div');
|
|
3597
|
+
fieldDiv.className = 'br-row-editor-field';
|
|
3598
|
+
|
|
3599
|
+
var label = document.createElement('div');
|
|
3600
|
+
label.className = 'br-row-editor-label';
|
|
3601
|
+
if (field.required) label.className += ' required';
|
|
3602
|
+
label.textContent = field.label || field.key;
|
|
3603
|
+
fieldDiv.appendChild(label);
|
|
3604
|
+
|
|
3605
|
+
var inputWrap = document.createElement('div');
|
|
3606
|
+
inputWrap.className = 'br-row-editor-input';
|
|
3607
|
+
|
|
3608
|
+
var inputEl;
|
|
3609
|
+
if (field.enum) {
|
|
3610
|
+
inputEl = document.createElement('select');
|
|
3611
|
+
if (!field.required) {
|
|
3612
|
+
var empty = document.createElement('option');
|
|
3613
|
+
empty.value = ''; empty.textContent = '—';
|
|
3614
|
+
inputEl.appendChild(empty);
|
|
3615
|
+
}
|
|
3616
|
+
field.enum.forEach(function(opt) {
|
|
3617
|
+
var optionEl = document.createElement('option');
|
|
3618
|
+
if (typeof opt === 'object' && opt !== null) {
|
|
3619
|
+
optionEl.value = opt.value; optionEl.textContent = opt.label || opt.value;
|
|
3620
|
+
} else {
|
|
3621
|
+
optionEl.value = opt; optionEl.textContent = opt;
|
|
3622
|
+
}
|
|
3623
|
+
if (field.default !== undefined && String(optionEl.value) === String(field.default)) optionEl.selected = true;
|
|
3624
|
+
inputEl.appendChild(optionEl);
|
|
3625
|
+
});
|
|
3626
|
+
} else if (field.format === 'date' || field.type === 'date') {
|
|
3627
|
+
inputEl = document.createElement('input');
|
|
3628
|
+
inputEl.type = 'date';
|
|
3629
|
+
if (field.default !== undefined) inputEl.value = field.default;
|
|
3630
|
+
} else if (field.type === 'integer' || field.type === 'number') {
|
|
3631
|
+
inputEl = document.createElement('input');
|
|
3632
|
+
inputEl.type = 'number';
|
|
3633
|
+
inputEl.step = (field.type === 'integer') ? '1' : 'any';
|
|
3634
|
+
if (field.minimum !== undefined) inputEl.min = field.minimum;
|
|
3635
|
+
if (field.maximum !== undefined) inputEl.max = field.maximum;
|
|
3636
|
+
if (field.default !== undefined) inputEl.value = field.default;
|
|
3637
|
+
} else if (field.format === 'email') {
|
|
3638
|
+
inputEl = document.createElement('input');
|
|
3639
|
+
inputEl.type = 'email';
|
|
3640
|
+
if (field.default !== undefined) inputEl.value = field.default;
|
|
3641
|
+
} else {
|
|
3642
|
+
inputEl = document.createElement('input');
|
|
3643
|
+
inputEl.type = 'text';
|
|
3644
|
+
if (field.pattern) inputEl.pattern = field.pattern;
|
|
3645
|
+
if (field.maxLength) inputEl.maxLength = field.maxLength;
|
|
3646
|
+
if (field.default !== undefined) inputEl.value = field.default;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
inputEl.setAttribute('data-field-key', field.key);
|
|
3650
|
+
inputWrap.appendChild(inputEl);
|
|
3651
|
+
fieldDiv.appendChild(inputWrap);
|
|
3652
|
+
form.appendChild(fieldDiv);
|
|
3653
|
+
fieldEls[field.key] = inputEl;
|
|
3654
|
+
});
|
|
3655
|
+
|
|
3656
|
+
body.appendChild(form);
|
|
3657
|
+
|
|
3658
|
+
var actions = document.createElement('div');
|
|
3659
|
+
actions.className = 'br-row-editor-actions';
|
|
3660
|
+
var cancelBtn = document.createElement('button');
|
|
3661
|
+
cancelBtn.className = 'br-re-cancel';
|
|
3662
|
+
cancelBtn.textContent = 'Cancel';
|
|
3663
|
+
cancelBtn.addEventListener('click', function() { dialog.remove(); });
|
|
3664
|
+
var submitBtn = document.createElement('button');
|
|
3665
|
+
submitBtn.className = 'br-re-save';
|
|
3666
|
+
submitBtn.textContent = 'Continue';
|
|
3667
|
+
submitBtn.addEventListener('click', function() {
|
|
3668
|
+
var values = {};
|
|
3669
|
+
var missing = [];
|
|
3670
|
+
(prompt.fields || []).forEach(function(field) {
|
|
3671
|
+
var el = fieldEls[field.key];
|
|
3672
|
+
var v = el.value;
|
|
3673
|
+
if (field.required && (v === '' || v === null || v === undefined)) missing.push(field.label || field.key);
|
|
3674
|
+
if (field.type === 'integer' && v !== '') v = parseInt(v, 10);
|
|
3675
|
+
else if (field.type === 'number' && v !== '') v = parseFloat(v);
|
|
3676
|
+
values[field.key] = (v === '') ? null : v;
|
|
3677
|
+
});
|
|
3678
|
+
if (missing.length) { alert('Required: ' + missing.join(', ')); return; }
|
|
3679
|
+
dialog.remove();
|
|
3680
|
+
onSubmit(values);
|
|
3681
|
+
});
|
|
3682
|
+
actions.appendChild(cancelBtn);
|
|
3683
|
+
actions.appendChild(submitBtn);
|
|
3684
|
+
body.appendChild(actions);
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
function deleteRow(dbKey, tableKey, rowIdx) {
|
|
3688
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
3689
|
+
'/tables/' + encodeURIComponent(tableKey) +
|
|
3690
|
+
'/rows/' + rowIdx;
|
|
3691
|
+
|
|
3692
|
+
fetch(url, { method: 'DELETE' })
|
|
3693
|
+
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, status: r.status, data: j }; }); })
|
|
3694
|
+
.then(function(res) {
|
|
3695
|
+
if (res.status === 403) { showAdminRequired(res.data); return; }
|
|
3696
|
+
if (res.ok && res.data.deleted) {
|
|
3697
|
+
// Remove from local data and re-index subsequent rows
|
|
3698
|
+
var idx = originalRows.findIndex(function(r) { return r[0] === rowIdx; });
|
|
3699
|
+
if (idx >= 0) originalRows.splice(idx, 1);
|
|
3700
|
+
// Row indices shift down after deletion — update all rows after the deleted one
|
|
3701
|
+
originalRows.forEach(function(r) { if (r[0] > rowIdx) r[0]--; });
|
|
3702
|
+
// Clean up dirty state for deleted/shifted rows
|
|
3703
|
+
var newDirty = {};
|
|
3704
|
+
Object.keys(dirtyRows).forEach(function(k) {
|
|
3705
|
+
var ki = parseInt(k);
|
|
3706
|
+
if (ki < rowIdx) newDirty[ki] = dirtyRows[ki];
|
|
3707
|
+
else if (ki > rowIdx) newDirty[ki - 1] = dirtyRows[ki];
|
|
3708
|
+
});
|
|
3709
|
+
dirtyRows = newDirty;
|
|
3710
|
+
if (selectedRowIdx === rowIdx) selectedRowIdx = null;
|
|
3711
|
+
else if (selectedRowIdx !== null && selectedRowIdx > rowIdx) selectedRowIdx--;
|
|
3712
|
+
render();
|
|
3713
|
+
} else {
|
|
3714
|
+
var msg = res.data.error || 'Delete failed';
|
|
3715
|
+
alert('Error: ' + msg);
|
|
3716
|
+
}
|
|
3717
|
+
})
|
|
3718
|
+
.catch(function(err) { alert('Delete failed: ' + err.message); });
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
function duplicateRow(dbKey, tableKey, rowIdx) {
|
|
3722
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
3723
|
+
'/tables/' + encodeURIComponent(tableKey) +
|
|
3724
|
+
'/rows/' + rowIdx + '/duplicate';
|
|
3725
|
+
|
|
3726
|
+
fetch(url, { method: 'POST' })
|
|
3727
|
+
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, status: r.status, data: j }; }); })
|
|
3728
|
+
.then(function(res) {
|
|
3729
|
+
if (res.status === 403) { showAdminRequired(res.data); return; }
|
|
3730
|
+
if (res.ok && res.data.duplicated) {
|
|
3731
|
+
var srcIdx = originalRows.findIndex(function(r) { return r[0] === rowIdx; });
|
|
3732
|
+
if (srcIdx < 0) { render(); return; }
|
|
3733
|
+
// Shift up indices of rows after the source
|
|
3734
|
+
originalRows.forEach(function(r) { if (r[0] > rowIdx) r[0]++; });
|
|
3735
|
+
// Insert an identical copy with index rowIdx + 1
|
|
3736
|
+
var newRow = originalRows[srcIdx].slice();
|
|
3737
|
+
newRow[0] = rowIdx + 1;
|
|
3738
|
+
originalRows.splice(srcIdx + 1, 0, newRow);
|
|
3739
|
+
// Shift dirty state for rows after the source
|
|
3740
|
+
var newDirty = {};
|
|
3741
|
+
Object.keys(dirtyRows).forEach(function(k) {
|
|
3742
|
+
var ki = parseInt(k);
|
|
3743
|
+
if (ki <= rowIdx) newDirty[ki] = dirtyRows[ki];
|
|
3744
|
+
else newDirty[ki + 1] = dirtyRows[ki];
|
|
3745
|
+
});
|
|
3746
|
+
dirtyRows = newDirty;
|
|
3747
|
+
// Shift any existing selection that sits after the source
|
|
3748
|
+
if (selectedRowIdx !== null && selectedRowIdx > rowIdx) selectedRowIdx++;
|
|
3749
|
+
// Highlight the new row
|
|
3750
|
+
selectedRowIdx = res.data.new_index;
|
|
3751
|
+
render();
|
|
3752
|
+
var newTr = tableWrap.querySelector('tr[data-row-idx="' + selectedRowIdx + '"]');
|
|
3753
|
+
if (newTr) newTr.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
3754
|
+
} else {
|
|
3755
|
+
var msg = res.data.error || 'Duplicate failed';
|
|
3756
|
+
alert('Error: ' + msg);
|
|
3757
|
+
}
|
|
3758
|
+
})
|
|
3759
|
+
.catch(function(err) { alert('Duplicate failed: ' + err.message); });
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
// Expose state for layout save/restore
|
|
3763
|
+
var _tableKey = data.table || data.table_key;
|
|
3764
|
+
var _viewKey = data.view || data.view_key || 'all';
|
|
3765
|
+
popup._getLayoutState = function() {
|
|
3766
|
+
var titleEl = popup.querySelector('.br-popup-title');
|
|
3767
|
+
var fullTitle = titleEl ? titleEl.textContent : '';
|
|
3768
|
+
var tableLabel = fullTitle.split(' \u2014 ')[0] || _tableKey;
|
|
3769
|
+
var state = {
|
|
3770
|
+
type: 'csv-table',
|
|
3771
|
+
dbKey: dbKey,
|
|
3772
|
+
tableKey: _tableKey,
|
|
3773
|
+
tableLabel: tableLabel,
|
|
3774
|
+
viewKey: _viewKey,
|
|
3775
|
+
tableColor: data.color || null,
|
|
3776
|
+
searchQuery: searchInput.value || '',
|
|
3777
|
+
colFilterValues: Object.assign({}, colFilterValues),
|
|
3778
|
+
colFiltersVisible: colFiltersVisible,
|
|
3779
|
+
showRowNumbers: showRowNumbers
|
|
3780
|
+
};
|
|
3781
|
+
if (Object.keys(colWidths).length > 0) state.colWidths = Object.assign({}, colWidths);
|
|
3782
|
+
return state;
|
|
3783
|
+
};
|
|
3784
|
+
|
|
3785
|
+
render();
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
// ── CSV Databases list (for /csv-browser start mode) ──
|
|
3789
|
+
|
|
3790
|
+
function reloadDatabases() {
|
|
3791
|
+
fetch('/browser/api/csv/reload', { method: 'POST' })
|
|
3792
|
+
.then(function(r) { return r.json(); })
|
|
3793
|
+
.then(function(databases) {
|
|
3794
|
+
csvDatabases = databases;
|
|
3795
|
+
hasCsvDatabases = Array.isArray(csvDatabases) && csvDatabases.length > 0;
|
|
3796
|
+
// Refresh any open database popups in the active tab
|
|
3797
|
+
var tabId = activeTabId();
|
|
3798
|
+
var popups = document.querySelectorAll('.br-popup[data-tab-id="' + tabId + '"]');
|
|
3799
|
+
popups.forEach(function(popup) {
|
|
3800
|
+
if (!popup._getLayoutState) return;
|
|
3801
|
+
var state = popup._getLayoutState();
|
|
3802
|
+
if (state.type === 'db-list') {
|
|
3803
|
+
popup.remove();
|
|
3804
|
+
showDatabaseList(csvDatabases, {});
|
|
3805
|
+
} else if (state.type === 'db') {
|
|
3806
|
+
var db = csvDatabases.find(function(d) { return d.key === state.dbKey; });
|
|
3807
|
+
if (db) {
|
|
3808
|
+
var rect = { x: parseInt(popup.style.left), y: parseInt(popup.style.top),
|
|
3809
|
+
width: popup.offsetWidth, height: popup.offsetHeight };
|
|
3810
|
+
popup.remove();
|
|
3811
|
+
showSingleDatabase(db, rect);
|
|
3812
|
+
} else {
|
|
3813
|
+
popup.remove();
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
});
|
|
3817
|
+
});
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3820
|
+
function showDatabaseList(databases, opts) {
|
|
3821
|
+
if (databases.length === 0) {
|
|
3822
|
+
createPopup({ title: 'Databases', html: '<div class="br-empty">No databases configured</div>', pinned: true, noClose: true, x: 8, y: TAB_BAR_HEIGHT + 8 });
|
|
3823
|
+
return;
|
|
3824
|
+
}
|
|
3825
|
+
// Skip straight to single database only when there are no virtual databases
|
|
3826
|
+
var realDbs = databases.filter(function(d) { return !d.virtual; });
|
|
3827
|
+
if (databases.length === 1 && realDbs.length === 1) {
|
|
3828
|
+
showSingleDatabase(databases[0], opts);
|
|
3829
|
+
return;
|
|
3830
|
+
}
|
|
3831
|
+
var html = '';
|
|
3832
|
+
databases.forEach(function(db) {
|
|
3833
|
+
var cls = 'br-list-item' + (db.virtual ? ' br-list-item-muted' : '');
|
|
3834
|
+
var label = escHtml(db.title);
|
|
3835
|
+
if (db.virtual) label += ' <span style="opacity:0.5;font-size:0.85em">(' + db.tables.length + ')</span>';
|
|
3836
|
+
var dupIcon = '<span class="br-dup-btn" data-dup-db="' + escHtml(db.key) + '">+</span>';
|
|
3837
|
+
html += '<button class="' + cls + '" data-db="' + db.key + '">' + dupIcon + label + '</button>';
|
|
3838
|
+
});
|
|
3839
|
+
var popup = createPopup({ id: 'db-list', title: 'Databases', pinned: true, noClose: true, x: 8, y: TAB_BAR_HEIGHT + 8 });
|
|
3840
|
+
popup._getLayoutState = function() { return { type: 'db-list' }; };
|
|
3841
|
+
popup.querySelector('.br-popup-body').innerHTML = html;
|
|
3842
|
+
popup.querySelector('.br-popup-body').style.padding = '0.4rem 0.5rem';
|
|
3843
|
+
popup.querySelector('.br-popup-body').addEventListener('click', function(e) {
|
|
3844
|
+
var dupBtn = e.target.closest('.br-dup-btn');
|
|
3845
|
+
if (dupBtn) {
|
|
3846
|
+
var dupDbKey = dupBtn.getAttribute('data-dup-db');
|
|
3847
|
+
var dupDb = databases.find(function(d) { return d.key === dupDbKey; });
|
|
3848
|
+
if (dupDb) showSingleDatabase(dupDb, { forceNew: true });
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3851
|
+
var btn = e.target.closest('[data-db]');
|
|
3852
|
+
if (!btn) return;
|
|
3853
|
+
var dbKey = btn.getAttribute('data-db');
|
|
3854
|
+
var db = databases.find(function(d) { return d.key === dbKey; });
|
|
3855
|
+
if (db) showSingleDatabase(db, {});
|
|
3856
|
+
});
|
|
3857
|
+
popup.querySelector('.br-popup-body').addEventListener('contextmenu', function(e) {
|
|
3858
|
+
var btn = e.target.closest('[data-db]');
|
|
3859
|
+
if (!btn) return;
|
|
3860
|
+
var dbKey = btn.getAttribute('data-db');
|
|
3861
|
+
var db = databases.find(function(d) { return d.key === dbKey; });
|
|
3862
|
+
if (!db || db.virtual) return;
|
|
3863
|
+
e.preventDefault();
|
|
3864
|
+
e.stopPropagation();
|
|
3865
|
+
|
|
3866
|
+
var existing = document.querySelector('.br-context-menu');
|
|
3867
|
+
if (existing) existing.remove();
|
|
3868
|
+
|
|
3869
|
+
var menu = document.createElement('div');
|
|
3870
|
+
menu.className = 'br-context-menu';
|
|
3871
|
+
menu.style.left = e.clientX + 'px';
|
|
3872
|
+
menu.style.top = e.clientY + 'px';
|
|
3873
|
+
|
|
3874
|
+
var viewYamlBtn = document.createElement('button');
|
|
3875
|
+
viewYamlBtn.className = 'br-context-menu-item default';
|
|
3876
|
+
viewYamlBtn.textContent = 'View yaml';
|
|
3877
|
+
viewYamlBtn.addEventListener('click', function() {
|
|
3878
|
+
menu.remove();
|
|
3879
|
+
loadContent(db.yaml_path, { title: db.title + ' (yaml)', queryParams: 'raw=1' });
|
|
3880
|
+
});
|
|
3881
|
+
menu.appendChild(viewYamlBtn);
|
|
3882
|
+
|
|
3883
|
+
document.body.appendChild(menu);
|
|
3884
|
+
fitMenuInViewport(menu);
|
|
3885
|
+
|
|
3886
|
+
setTimeout(function() {
|
|
3887
|
+
document.addEventListener('mousedown', function handler(ev) {
|
|
3888
|
+
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', handler); }
|
|
3889
|
+
});
|
|
3890
|
+
}, 0);
|
|
3891
|
+
});
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
function showSingleDatabase(db, opts) {
|
|
3895
|
+
var baseId = 'db:' + db.key;
|
|
3896
|
+
var popupId = baseId;
|
|
3897
|
+
if (opts && opts.forceNew) popupId += ':dup:' + (++dupCounter);
|
|
3898
|
+
var existing = (opts && opts.forceNew) ? findPopup(popupId) : findPopupOrDup(baseId);
|
|
3899
|
+
if (existing) { bringToFront(existing); return; }
|
|
3900
|
+
|
|
3901
|
+
var pos = (opts && opts.x !== undefined) ? { x: opts.x, y: opts.y }
|
|
3902
|
+
: (opts && opts.noClose) ? { x: 8, y: TAB_BAR_HEIGHT + 8 } : nextPosition();
|
|
3903
|
+
var popup = createPopup({
|
|
3904
|
+
id: popupId, title: db.title, pinned: true,
|
|
3905
|
+
noClose: !!(opts && opts.noClose),
|
|
3906
|
+
x: pos.x, y: pos.y,
|
|
3907
|
+
width: opts && opts.width, height: opts && opts.height
|
|
3908
|
+
});
|
|
3909
|
+
popup._getLayoutState = function() {
|
|
3910
|
+
return { type: 'db', dbKey: db.key };
|
|
3911
|
+
};
|
|
3912
|
+
var body = popup.querySelector('.br-popup-body');
|
|
3913
|
+
body.style.padding = '0.4rem 0.5rem';
|
|
3914
|
+
|
|
3915
|
+
if (!db.virtual) {
|
|
3916
|
+
var searchInput = document.createElement('input');
|
|
3917
|
+
searchInput.className = 'br-search';
|
|
3918
|
+
searchInput.type = 'search';
|
|
3919
|
+
searchInput.placeholder = 'Search all tables\u2026';
|
|
3920
|
+
searchInput.style.marginBottom = '0.4rem';
|
|
3921
|
+
body.appendChild(searchInput);
|
|
3922
|
+
searchInput.addEventListener('keydown', function(e) {
|
|
3923
|
+
if (e.key === 'Enter') {
|
|
3924
|
+
var q = searchInput.value.trim();
|
|
3925
|
+
if (q) showDatabaseSearch(db, q);
|
|
3926
|
+
}
|
|
3927
|
+
});
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
var listDiv = document.createElement('div');
|
|
3931
|
+
var html = '';
|
|
3932
|
+
var lastGroup = null;
|
|
3933
|
+
db.tables.forEach(function(t) {
|
|
3934
|
+
if (t.group && t.group !== lastGroup) {
|
|
3935
|
+
lastGroup = t.group;
|
|
3936
|
+
html += '<div class="br-group-header">' + escHtml(t.group) + '</div>';
|
|
3937
|
+
}
|
|
3938
|
+
var style = t.color ? ' style="--row-color:' + t.color + '"' : '';
|
|
3939
|
+
var label = escHtml(t.title);
|
|
3940
|
+
if (t.record_count != null) label += ' <span class="br-record-count">(' + t.record_count + ')</span>';
|
|
3941
|
+
var dupIcon = '<span class="br-dup-btn" data-dup-table="' + escHtml(t.key) + '" data-dup-table-db="' + escHtml(db.key) + '">+</span>';
|
|
3942
|
+
var infoIcon = !db.virtual ? '<span class="br-info-icon" data-schema="' + escHtml(t.key) + '">i</span>' : '';
|
|
3943
|
+
html += '<button class="br-list-item" data-table="' + escHtml(t.key) + '"' + style + '>' + dupIcon + infoIcon + label + '</button>';
|
|
3944
|
+
});
|
|
3945
|
+
listDiv.innerHTML = html;
|
|
3946
|
+
body.appendChild(listDiv);
|
|
3947
|
+
|
|
3948
|
+
listDiv.addEventListener('click', function(e) {
|
|
3949
|
+
var dupBtn = e.target.closest('.br-dup-btn');
|
|
3950
|
+
if (dupBtn) {
|
|
3951
|
+
e.stopPropagation();
|
|
3952
|
+
var dupTableKey = dupBtn.getAttribute('data-dup-table');
|
|
3953
|
+
var dupTable = db.tables.find(function(t) { return t.key === dupTableKey; });
|
|
3954
|
+
if (dupTable) showCsvViewMenu(db.key, dupTable, e.clientX, e.clientY, true);
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
var icon = e.target.closest('[data-schema]');
|
|
3958
|
+
if (icon) {
|
|
3959
|
+
e.stopPropagation();
|
|
3960
|
+
var schemaKey = icon.getAttribute('data-schema');
|
|
3961
|
+
var schemaTable = db.tables.find(function(t) { return t.key === schemaKey; });
|
|
3962
|
+
showTableSchema(db.key, schemaKey, schemaTable && schemaTable.color, e.clientX, e.clientY);
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3965
|
+
var btn = e.target.closest('[data-table]');
|
|
3966
|
+
if (!btn) return;
|
|
3967
|
+
var tableKey = btn.getAttribute('data-table');
|
|
3968
|
+
var table = db.tables.find(function(t) { return t.key === tableKey; });
|
|
3969
|
+
if (table) openDefaultView(db.key, table);
|
|
3970
|
+
});
|
|
3971
|
+
|
|
3972
|
+
listDiv.addEventListener('contextmenu', function(e) {
|
|
3973
|
+
var btn = e.target.closest('[data-table]');
|
|
3974
|
+
if (!btn) return;
|
|
3975
|
+
e.preventDefault();
|
|
3976
|
+
e.stopPropagation();
|
|
3977
|
+
var tableKey = btn.getAttribute('data-table');
|
|
3978
|
+
var table = db.tables.find(function(t) { return t.key === tableKey; });
|
|
3979
|
+
if (!table || table.views.length === 0) return;
|
|
3980
|
+
|
|
3981
|
+
var existing = document.querySelector('.br-context-menu');
|
|
3982
|
+
if (existing) existing.remove();
|
|
3983
|
+
|
|
3984
|
+
var menu = document.createElement('div');
|
|
3985
|
+
menu.className = 'br-context-menu';
|
|
3986
|
+
menu.style.left = e.clientX + 'px';
|
|
3987
|
+
menu.style.top = e.clientY + 'px';
|
|
3988
|
+
|
|
3989
|
+
// Default view (saved or first view)
|
|
3990
|
+
var defaultBtn = document.createElement('button');
|
|
3991
|
+
defaultBtn.className = 'br-context-menu-item default';
|
|
3992
|
+
defaultBtn.textContent = 'Default view';
|
|
3993
|
+
defaultBtn.addEventListener('click', function() {
|
|
3994
|
+
menu.remove();
|
|
3995
|
+
openDefaultView(db.key, table);
|
|
3996
|
+
});
|
|
3997
|
+
menu.appendChild(defaultBtn);
|
|
3998
|
+
|
|
3999
|
+
if (table.views.length > 1) {
|
|
4000
|
+
table.views.forEach(function(v) {
|
|
4001
|
+
var vBtn = document.createElement('button');
|
|
4002
|
+
vBtn.className = 'br-context-menu-item default';
|
|
4003
|
+
vBtn.textContent = v.title;
|
|
4004
|
+
vBtn.addEventListener('click', function() {
|
|
4005
|
+
menu.remove();
|
|
4006
|
+
openCsvTablePopup(db.key, table.key, table.title, v.key, table.color);
|
|
4007
|
+
});
|
|
4008
|
+
menu.appendChild(vBtn);
|
|
4009
|
+
});
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
document.body.appendChild(menu);
|
|
4013
|
+
fitMenuInViewport(menu);
|
|
4014
|
+
|
|
4015
|
+
setTimeout(function() {
|
|
4016
|
+
document.addEventListener('mousedown', function handler(ev) {
|
|
4017
|
+
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', handler); }
|
|
4018
|
+
});
|
|
4019
|
+
}, 0);
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
function showDatabaseSearch(db, query, layoutOpts) {
|
|
4024
|
+
var popupId = 'dbsearch:' + db.key + ':' + query;
|
|
4025
|
+
var existing = findPopup(popupId);
|
|
4026
|
+
if (existing) { bringToFront(existing); return; }
|
|
4027
|
+
|
|
4028
|
+
layoutOpts = layoutOpts || {};
|
|
4029
|
+
var pos = (layoutOpts.x !== undefined) ? { x: layoutOpts.x, y: layoutOpts.y } : nextPosition();
|
|
4030
|
+
var popup = createPopup({
|
|
4031
|
+
id: popupId,
|
|
4032
|
+
title: db.title + ', ' + query,
|
|
4033
|
+
pinned: true, x: pos.x, y: pos.y,
|
|
4034
|
+
width: layoutOpts.width, height: layoutOpts.height
|
|
4035
|
+
});
|
|
4036
|
+
popup._getLayoutState = function() {
|
|
4037
|
+
return { type: 'db-search', dbKey: db.key, query: query };
|
|
4038
|
+
};
|
|
4039
|
+
var body = popup.querySelector('.br-popup-body');
|
|
4040
|
+
body.style.padding = '0.5rem';
|
|
4041
|
+
body.innerHTML = '<div class="br-empty">Searching\u2026</div>';
|
|
4042
|
+
|
|
4043
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(db.key) +
|
|
4044
|
+
'/search?q=' + encodeURIComponent(query);
|
|
4045
|
+
fetch(url)
|
|
4046
|
+
.then(function(r) { return r.json(); })
|
|
4047
|
+
.then(function(data) {
|
|
4048
|
+
if (!data.results || data.results.length === 0) {
|
|
4049
|
+
body.innerHTML = '<div class="br-empty">No matches</div>';
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
var html = '';
|
|
4053
|
+
data.results.forEach(function(r) {
|
|
4054
|
+
var style = r.color ? ' style="--row-color:' + r.color + '"' : '';
|
|
4055
|
+
html += '<button class="br-list-item" data-result-table="' + escHtml(r.table) + '"' + style + '>'
|
|
4056
|
+
+ escHtml(r.title) + ' <span class="br-record-count">(' + r.count + ')</span></button>';
|
|
4057
|
+
});
|
|
4058
|
+
body.innerHTML = html;
|
|
4059
|
+
body.addEventListener('click', function(e) {
|
|
4060
|
+
var btn = e.target.closest('[data-result-table]');
|
|
4061
|
+
if (!btn) return;
|
|
4062
|
+
var tableKey = btn.getAttribute('data-result-table');
|
|
4063
|
+
var result = data.results.find(function(r) { return r.table === tableKey; });
|
|
4064
|
+
if (!result) return;
|
|
4065
|
+
var viewKey = getDefaultViewKey(db.key, tableKey, result.views);
|
|
4066
|
+
openCsvTablePopup(db.key, tableKey, result.title, viewKey, result.color, { searchQuery: query });
|
|
4067
|
+
});
|
|
4068
|
+
})
|
|
4069
|
+
.catch(function() {
|
|
4070
|
+
body.innerHTML = '<div class="br-empty">Search failed</div>';
|
|
4071
|
+
});
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
function showTableSchema(dbKey, tableKey, tableColor, x, y, width, height) {
|
|
4075
|
+
var popupId = 'schema:' + dbKey + ':' + tableKey;
|
|
4076
|
+
var existing = findPopup(popupId);
|
|
4077
|
+
if (existing) { bringToFront(existing); return; }
|
|
4078
|
+
|
|
4079
|
+
var pos = nextPosition();
|
|
4080
|
+
var popup = createPopup({
|
|
4081
|
+
id: popupId,
|
|
4082
|
+
title: tableKey + ' \u2014 schema',
|
|
4083
|
+
pinned: true, x: x || pos.x, y: y || pos.y,
|
|
4084
|
+
width: width, height: height,
|
|
4085
|
+
color: tableColor
|
|
4086
|
+
});
|
|
4087
|
+
popup._getLayoutState = function() {
|
|
4088
|
+
return { type: 'schema', dbKey: dbKey, tableKey: tableKey, tableColor: tableColor };
|
|
4089
|
+
};
|
|
4090
|
+
var body = popup.querySelector('.br-popup-body');
|
|
4091
|
+
body.style.padding = '0.75rem';
|
|
4092
|
+
if (tableColor) body.style.background = 'color-mix(in srgb, ' + tableColor + ' 10%, white)';
|
|
4093
|
+
body.innerHTML = '<div class="br-empty">Loading\u2026</div>';
|
|
4094
|
+
|
|
4095
|
+
var url = '/browser/api/csv/databases/' + encodeURIComponent(dbKey) +
|
|
4096
|
+
'/tables/' + encodeURIComponent(tableKey) + '/schema';
|
|
4097
|
+
fetch(url)
|
|
4098
|
+
.then(function(r) { return r.text(); })
|
|
4099
|
+
.then(function(yaml) {
|
|
4100
|
+
body.innerHTML = '<pre style="margin:0;font-size:0.8rem;white-space:pre-wrap;color:#3a3a3a;line-height:1.5">' + escHtml(yaml) + '</pre>';
|
|
4101
|
+
})
|
|
4102
|
+
.catch(function() {
|
|
4103
|
+
body.innerHTML = '<div class="br-empty">Error loading schema</div>';
|
|
4104
|
+
});
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
// ── Background context menu ──
|
|
4108
|
+
|
|
4109
|
+
var DEFAULT_LAYOUT = '--default layout--';
|
|
4110
|
+
|
|
4111
|
+
function fitMenuInViewport(menu) {
|
|
4112
|
+
var rect = menu.getBoundingClientRect();
|
|
4113
|
+
if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
|
|
4114
|
+
if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
function showStoreLayoutForm(menu) {
|
|
4118
|
+
menu.innerHTML = '';
|
|
4119
|
+
var label = document.createElement('div');
|
|
4120
|
+
label.className = 'br-context-menu-label';
|
|
4121
|
+
label.textContent = 'Save layout as:';
|
|
4122
|
+
menu.appendChild(label);
|
|
4123
|
+
|
|
4124
|
+
var form = document.createElement('div');
|
|
4125
|
+
form.className = 'br-context-menu-form';
|
|
4126
|
+
|
|
4127
|
+
var names = Object.keys(getLayouts());
|
|
4128
|
+
var listId = 'br-layout-names-' + Date.now();
|
|
4129
|
+
var datalist = document.createElement('datalist');
|
|
4130
|
+
datalist.id = listId;
|
|
4131
|
+
names.forEach(function(n) {
|
|
4132
|
+
var opt = document.createElement('option');
|
|
4133
|
+
opt.value = n;
|
|
4134
|
+
datalist.appendChild(opt);
|
|
4135
|
+
});
|
|
4136
|
+
form.appendChild(datalist);
|
|
4137
|
+
|
|
4138
|
+
var input = document.createElement('input');
|
|
4139
|
+
input.type = 'text';
|
|
4140
|
+
input.setAttribute('list', listId);
|
|
4141
|
+
input.value = currentLayoutName || DEFAULT_LAYOUT;
|
|
4142
|
+
input.placeholder = 'Layout name';
|
|
4143
|
+
form.appendChild(input);
|
|
4144
|
+
|
|
4145
|
+
var buttons = document.createElement('div');
|
|
4146
|
+
buttons.className = 'br-form-buttons';
|
|
4147
|
+
var saveBtn = document.createElement('button');
|
|
4148
|
+
saveBtn.className = 'br-btn-save';
|
|
4149
|
+
saveBtn.textContent = 'Save';
|
|
4150
|
+
saveBtn.addEventListener('click', function() {
|
|
4151
|
+
var name = input.value.trim();
|
|
4152
|
+
if (name) { saveLayout(name); }
|
|
4153
|
+
menu.remove();
|
|
4154
|
+
});
|
|
4155
|
+
var cancelBtn = document.createElement('button');
|
|
4156
|
+
cancelBtn.className = 'br-btn-cancel';
|
|
4157
|
+
cancelBtn.textContent = 'Cancel';
|
|
4158
|
+
cancelBtn.addEventListener('click', function() { menu.remove(); });
|
|
4159
|
+
buttons.appendChild(saveBtn);
|
|
4160
|
+
buttons.appendChild(cancelBtn);
|
|
4161
|
+
form.appendChild(buttons);
|
|
4162
|
+
|
|
4163
|
+
menu.appendChild(form);
|
|
4164
|
+
fitMenuInViewport(menu);
|
|
4165
|
+
input.focus();
|
|
4166
|
+
input.select();
|
|
4167
|
+
|
|
4168
|
+
input.addEventListener('keydown', function(ev) {
|
|
4169
|
+
if (ev.key === 'Enter') { saveBtn.click(); }
|
|
4170
|
+
else if (ev.key === 'Escape') { menu.remove(); }
|
|
4171
|
+
});
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
function showRestoreLayoutList(menu) {
|
|
4175
|
+
menu.innerHTML = '';
|
|
4176
|
+
var layouts = getLayouts();
|
|
4177
|
+
var names = Object.keys(layouts);
|
|
4178
|
+
|
|
4179
|
+
if (names.length === 0) {
|
|
4180
|
+
var msg = document.createElement('div');
|
|
4181
|
+
msg.className = 'br-context-menu-label';
|
|
4182
|
+
msg.textContent = 'No saved layouts yet.';
|
|
4183
|
+
menu.appendChild(msg);
|
|
4184
|
+
fitMenuInViewport(menu);
|
|
4185
|
+
return;
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
var label = document.createElement('div');
|
|
4189
|
+
label.className = 'br-context-menu-label';
|
|
4190
|
+
label.textContent = 'Restore layout:';
|
|
4191
|
+
menu.appendChild(label);
|
|
4192
|
+
|
|
4193
|
+
names.forEach(function(name) {
|
|
4194
|
+
var btn = document.createElement('button');
|
|
4195
|
+
btn.className = 'br-context-menu-item default';
|
|
4196
|
+
btn.textContent = name;
|
|
4197
|
+
btn.addEventListener('click', function() {
|
|
4198
|
+
menu.remove();
|
|
4199
|
+
restoreLayout(name);
|
|
4200
|
+
});
|
|
4201
|
+
menu.appendChild(btn);
|
|
4202
|
+
});
|
|
4203
|
+
fitMenuInViewport(menu);
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
function showManageLayouts(opts) {
|
|
4207
|
+
opts = opts || {};
|
|
4208
|
+
var popupId = 'manage-layouts';
|
|
4209
|
+
var existing = findPopup(popupId);
|
|
4210
|
+
if (existing) { existing.style.zIndex = ++zCounter; return; }
|
|
4211
|
+
|
|
4212
|
+
var popup = createPopup({ id: popupId, title: 'Manage Layouts', width: opts.width || 480,
|
|
4213
|
+
x: opts.x, y: opts.y, height: opts.height });
|
|
4214
|
+
if (opts.x !== undefined) { popup.style.left = opts.x + 'px'; popup.style.top = opts.y + 'px'; popup.style.transform = ''; }
|
|
4215
|
+
if (opts.width) popup.style.width = opts.width + 'px';
|
|
4216
|
+
if (opts.height) popup.style.height = opts.height + 'px';
|
|
4217
|
+
var body = popup.querySelector('.br-popup-body');
|
|
4218
|
+
body.style.padding = '0.75rem';
|
|
4219
|
+
|
|
4220
|
+
var sortCol = opts.sortCol || 'saved';
|
|
4221
|
+
var sortAsc = opts.sortAsc !== undefined ? opts.sortAsc : false;
|
|
4222
|
+
|
|
4223
|
+
function renderTable() {
|
|
4224
|
+
var layouts = getLayouts();
|
|
4225
|
+
var names = Object.keys(layouts);
|
|
4226
|
+
if (names.length === 0) {
|
|
4227
|
+
body.innerHTML = '<div class="br-empty">No saved layouts.</div>';
|
|
4228
|
+
return;
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
var rows = names.map(function(name) {
|
|
4232
|
+
var layout = layouts[name];
|
|
4233
|
+
return { name: name, popups: layout.popups ? layout.popups.length : 0, savedAt: layout.savedAt || '' };
|
|
4234
|
+
});
|
|
4235
|
+
|
|
4236
|
+
rows.sort(function(a, b) {
|
|
4237
|
+
var va, vb;
|
|
4238
|
+
if (sortCol === 'name') { va = a.name.toLowerCase(); vb = b.name.toLowerCase(); }
|
|
4239
|
+
else if (sortCol === 'popups') { va = a.popups; vb = b.popups; }
|
|
4240
|
+
else { va = a.savedAt; vb = b.savedAt; }
|
|
4241
|
+
if (va < vb) return sortAsc ? -1 : 1;
|
|
4242
|
+
if (va > vb) return sortAsc ? 1 : -1;
|
|
4243
|
+
return 0;
|
|
4244
|
+
});
|
|
4245
|
+
|
|
4246
|
+
var table = document.createElement('table');
|
|
4247
|
+
table.style.cssText = 'width:100%;border-collapse:collapse;font-size:0.82rem;';
|
|
4248
|
+
|
|
4249
|
+
var thStyle = 'padding:0.3rem 0.5rem;cursor:pointer;user-select:none;';
|
|
4250
|
+
var arrow = function(col) { return sortCol === col ? (sortAsc ? ' \u25B4' : ' \u25BE') : ''; };
|
|
4251
|
+
|
|
4252
|
+
var thead = document.createElement('thead');
|
|
4253
|
+
var headRow = document.createElement('tr');
|
|
4254
|
+
headRow.style.cssText = 'border-bottom:1px solid #d4b96a;color:#888;text-align:left';
|
|
4255
|
+
headRow.innerHTML =
|
|
4256
|
+
'<th style="padding:0.3rem 0.5rem;width:3.5rem"></th>' +
|
|
4257
|
+
'<th data-sort="name" style="' + thStyle + '">Name' + arrow('name') + '</th>' +
|
|
4258
|
+
'<th data-sort="popups" style="' + thStyle + 'text-align:right">Popups' + arrow('popups') + '</th>' +
|
|
4259
|
+
'<th data-sort="saved" style="' + thStyle + 'text-align:right">Saved' + arrow('saved') + '</th>';
|
|
4260
|
+
thead.appendChild(headRow);
|
|
4261
|
+
table.appendChild(thead);
|
|
4262
|
+
|
|
4263
|
+
headRow.addEventListener('click', function(e) {
|
|
4264
|
+
var th = e.target.closest('th[data-sort]');
|
|
4265
|
+
if (!th) return;
|
|
4266
|
+
var col = th.getAttribute('data-sort');
|
|
4267
|
+
if (sortCol === col) { sortAsc = !sortAsc; }
|
|
4268
|
+
else { sortCol = col; sortAsc = col === 'name'; }
|
|
4269
|
+
renderTable();
|
|
4270
|
+
});
|
|
4271
|
+
|
|
4272
|
+
var tbody = document.createElement('tbody');
|
|
4273
|
+
rows.forEach(function(row) {
|
|
4274
|
+
var tr = document.createElement('tr');
|
|
4275
|
+
tr.style.borderBottom = '1px solid rgba(212,185,106,0.3)';
|
|
4276
|
+
|
|
4277
|
+
var tdActions = document.createElement('td');
|
|
4278
|
+
tdActions.style.cssText = 'padding:0.3rem 0.5rem;text-align:center;white-space:nowrap';
|
|
4279
|
+
|
|
4280
|
+
var restoreBtn = document.createElement('button');
|
|
4281
|
+
restoreBtn.style.cssText = 'background:none;border:none;color:#27ae60;cursor:pointer;font-size:1rem;padding:0;line-height:1;margin-right:0.4rem';
|
|
4282
|
+
restoreBtn.innerHTML = '✔';
|
|
4283
|
+
restoreBtn.title = 'Restore layout';
|
|
4284
|
+
restoreBtn.addEventListener('click', function() {
|
|
4285
|
+
var rect = popup.getBoundingClientRect();
|
|
4286
|
+
var reopenOpts = {
|
|
4287
|
+
x: parseInt(popup.style.left) || Math.round(rect.left),
|
|
4288
|
+
y: parseInt(popup.style.top) || Math.round(rect.top),
|
|
4289
|
+
width: Math.round(rect.width), height: Math.round(rect.height),
|
|
4290
|
+
sortCol: sortCol, sortAsc: sortAsc
|
|
4291
|
+
};
|
|
4292
|
+
restoreLayout(row.name);
|
|
4293
|
+
showManageLayouts(reopenOpts);
|
|
4294
|
+
});
|
|
4295
|
+
tdActions.appendChild(restoreBtn);
|
|
4296
|
+
|
|
4297
|
+
var delBtn = document.createElement('button');
|
|
4298
|
+
delBtn.style.cssText = 'background:none;border:none;color:#c0392b;cursor:pointer;font-size:1rem;padding:0;line-height:1';
|
|
4299
|
+
delBtn.innerHTML = '✖';
|
|
4300
|
+
delBtn.title = 'Delete layout';
|
|
4301
|
+
delBtn.addEventListener('click', function() {
|
|
4302
|
+
deleteLayout(row.name);
|
|
4303
|
+
if (currentLayoutName === row.name) currentLayoutName = null;
|
|
4304
|
+
renderTable();
|
|
4305
|
+
});
|
|
4306
|
+
tdActions.appendChild(delBtn);
|
|
4307
|
+
tr.appendChild(tdActions);
|
|
4308
|
+
|
|
4309
|
+
var tdName = document.createElement('td');
|
|
4310
|
+
tdName.style.cssText = 'padding:0.3rem 0.5rem;color:#3a3a3a';
|
|
4311
|
+
tdName.textContent = row.name;
|
|
4312
|
+
tr.appendChild(tdName);
|
|
4313
|
+
|
|
4314
|
+
var tdCount = document.createElement('td');
|
|
4315
|
+
tdCount.style.cssText = 'padding:0.3rem 0.5rem;text-align:right;color:#888';
|
|
4316
|
+
tdCount.textContent = row.popups;
|
|
4317
|
+
tr.appendChild(tdCount);
|
|
4318
|
+
|
|
4319
|
+
var tdDate = document.createElement('td');
|
|
4320
|
+
tdDate.style.cssText = 'padding:0.3rem 0.5rem;text-align:right;color:#888;white-space:nowrap';
|
|
4321
|
+
if (row.savedAt) {
|
|
4322
|
+
var d = new Date(row.savedAt);
|
|
4323
|
+
tdDate.textContent = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
4324
|
+
}
|
|
4325
|
+
tr.appendChild(tdDate);
|
|
4326
|
+
|
|
4327
|
+
tbody.appendChild(tr);
|
|
4328
|
+
});
|
|
4329
|
+
table.appendChild(tbody);
|
|
4330
|
+
body.innerHTML = '';
|
|
4331
|
+
body.appendChild(table);
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
renderTable();
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
document.addEventListener('contextmenu', function(e) {
|
|
4338
|
+
// Only trigger on the page background, not inside popups or tab bar
|
|
4339
|
+
if (e.target.closest('.br-popup') || e.target.closest('.br-tab-bar')) return;
|
|
4340
|
+
e.preventDefault();
|
|
4341
|
+
|
|
4342
|
+
var existing = document.querySelector('.br-context-menu');
|
|
4343
|
+
if (existing) existing.remove();
|
|
4344
|
+
|
|
4345
|
+
var menu = document.createElement('div');
|
|
4346
|
+
menu.className = 'br-context-menu';
|
|
4347
|
+
menu.style.left = e.clientX + 'px';
|
|
4348
|
+
menu.style.top = e.clientY + 'px';
|
|
4349
|
+
|
|
4350
|
+
var items = [
|
|
4351
|
+
{ label: 'Store layout', action: function() { showStoreLayoutForm(menu); } },
|
|
4352
|
+
{ label: 'Restore layout', action: function() { showRestoreLayoutList(menu); } },
|
|
4353
|
+
{ label: 'Manage layouts', action: function() { menu.remove(); showManageLayouts(); } },
|
|
4354
|
+
'sep',
|
|
4355
|
+
{ label: 'Open databases', action: function() { showDatabaseList(csvDatabases, {}); } },
|
|
4356
|
+
{ label: 'Open root directory', action: function() { loadContent('', { title: rootTitle }); } },
|
|
4357
|
+
'sep',
|
|
4358
|
+
{ label: 'Reload databases', action: function() { reloadDatabases(); } }
|
|
4359
|
+
];
|
|
4360
|
+
|
|
4361
|
+
items.forEach(function(item) {
|
|
4362
|
+
if (item === 'sep') {
|
|
4363
|
+
var sep = document.createElement('div');
|
|
4364
|
+
sep.className = 'br-context-menu-sep';
|
|
4365
|
+
menu.appendChild(sep);
|
|
4366
|
+
return;
|
|
4367
|
+
}
|
|
4368
|
+
var btn = document.createElement('button');
|
|
4369
|
+
btn.className = 'br-context-menu-item default';
|
|
4370
|
+
btn.textContent = item.label;
|
|
4371
|
+
btn.addEventListener('click', function() {
|
|
4372
|
+
item.action();
|
|
4373
|
+
});
|
|
4374
|
+
menu.appendChild(btn);
|
|
4375
|
+
});
|
|
4376
|
+
|
|
4377
|
+
document.body.appendChild(menu);
|
|
4378
|
+
fitMenuInViewport(menu);
|
|
4379
|
+
|
|
4380
|
+
// Dismiss on click outside
|
|
4381
|
+
setTimeout(function() {
|
|
4382
|
+
document.addEventListener('mousedown', function handler(ev) {
|
|
4383
|
+
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('mousedown', handler); }
|
|
4384
|
+
});
|
|
4385
|
+
}, 0);
|
|
4386
|
+
});
|
|
4387
|
+
|
|
4388
|
+
// ── Start ──
|
|
4389
|
+
|
|
4390
|
+
function init() {
|
|
4391
|
+
var mode = startMode === 'csv' ? 'csv' : 'directory';
|
|
4392
|
+
tabCounter++;
|
|
4393
|
+
var firstTab = { id: tabCounter, name: '1', mode: mode };
|
|
4394
|
+
tabs.push(firstTab);
|
|
4395
|
+
activeTabId = firstTab.id;
|
|
4396
|
+
renderTabBar();
|
|
4397
|
+
initNewTab(firstTab);
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
if (document.readyState === 'loading') {
|
|
4401
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
4402
|
+
} else {
|
|
4403
|
+
init();
|
|
4404
|
+
}
|
|
4405
|
+
})();
|
|
4406
|
+
</script>
|
|
4407
|
+
</body>
|
|
4408
|
+
</html>
|