@harshankur/viewcounter 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
@@ -233,6 +233,15 @@ textarea {
233
233
  mix-blend-mode: overlay;
234
234
  }
235
235
 
236
+ /* ---------- Icons ---------- */
237
+
238
+ /* Line icons take the size and colour of the text around them. */
239
+ .icon {
240
+ width: 1.1em;
241
+ height: 1.1em;
242
+ flex-shrink: 0;
243
+ }
244
+
236
245
  /* ---------- Top bar ---------- */
237
246
 
238
247
  .topbar {
@@ -281,12 +290,35 @@ textarea {
281
290
  padding: 0.05rem 0.4rem;
282
291
  }
283
292
 
293
+ .version-badge {
294
+ font-size: 0.72rem;
295
+ color: var(--text-muted);
296
+ font-variant-numeric: tabular-nums;
297
+ }
298
+
284
299
  .topbar-actions {
285
300
  display: flex;
286
301
  align-items: center;
287
302
  gap: 0.75rem;
288
303
  }
289
304
 
305
+ .header-link {
306
+ display: inline-flex;
307
+ align-items: center;
308
+ gap: 0.4rem;
309
+ color: var(--text-muted);
310
+ font-size: 0.85rem;
311
+ font-weight: 600;
312
+ text-decoration: none;
313
+ padding: 0.35rem 0.5rem;
314
+ border-radius: var(--radius-small);
315
+ }
316
+
317
+ .header-link:hover {
318
+ color: var(--primary);
319
+ background: var(--row-hover);
320
+ }
321
+
290
322
  .theme-switcher {
291
323
  display: flex;
292
324
  background: var(--code-bg);
@@ -321,12 +353,18 @@ textarea {
321
353
  box-shadow: 0 0 12px var(--primary-glow);
322
354
  }
323
355
 
356
+ .theme-btn .icon {
357
+ width: 16px;
358
+ height: 16px;
359
+ }
360
+
324
361
  /* ---------- Layout ---------- */
325
362
 
363
+ /* Wide enough that a large screen shows more columns and wider charts. */
326
364
  main {
327
- max-width: 1440px;
365
+ max-width: 1920px;
328
366
  margin: 0 auto;
329
- padding: 1.75rem 2rem 6rem;
367
+ padding: 1.75rem 2rem 3rem;
330
368
  }
331
369
 
332
370
  /* ---------- Terminal window chrome ---------- */
@@ -733,7 +771,7 @@ textarea.input {
733
771
 
734
772
  .tab {
735
773
  display: inline-flex;
736
- gap: 0.4rem;
774
+ gap: 0.5rem;
737
775
  align-items: center;
738
776
  padding: 0.7rem 1rem;
739
777
  border: none;
@@ -745,8 +783,8 @@ textarea.input {
745
783
  margin-bottom: -1px;
746
784
  }
747
785
 
748
- .tab .prompt-char {
749
- opacity: 0.4;
786
+ .tab .icon {
787
+ opacity: 0.7;
750
788
  }
751
789
 
752
790
  .tab:hover {
@@ -758,7 +796,7 @@ textarea.input {
758
796
  border-bottom-color: var(--primary);
759
797
  }
760
798
 
761
- .tab.active .prompt-char {
799
+ .tab.active .icon {
762
800
  opacity: 1;
763
801
  }
764
802
 
@@ -810,10 +848,6 @@ textarea.input {
810
848
  transition: color 0.15s, border-color 0.15s, background-color 0.15s, box-shadow 0.2s;
811
849
  }
812
850
 
813
- .app-tab .prompt-char {
814
- opacity: 0.4;
815
- }
816
-
817
851
  .app-tab:hover {
818
852
  color: var(--text);
819
853
  border-color: var(--border-strong);
@@ -826,10 +860,6 @@ textarea.input {
826
860
  box-shadow: 0 0 12px var(--primary-glow);
827
861
  }
828
862
 
829
- .app-tab.active .prompt-char {
830
- opacity: 1;
831
- }
832
-
833
863
  .app-tab.unavailable {
834
864
  opacity: 0.55;
835
865
  text-decoration: line-through;
@@ -854,95 +884,528 @@ textarea.input {
854
884
  font-size: 0.85rem;
855
885
  }
856
886
 
857
- /* ---------- Insights ---------- */
858
-
859
- .insights {
887
+ /* Each section says what it is and how it differs from the others. */
888
+ .panel-header {
860
889
  display: flex;
861
890
  flex-direction: column;
862
- gap: 0.9rem;
863
- transition: opacity 0.2s;
891
+ gap: 0.3rem;
864
892
  }
865
893
 
866
- /* Refetch keeps the frame: the last render stays, dimmed, until the new one. */
867
- .insights.loading .insights-body {
868
- opacity: 0.55;
894
+ .panel-title {
895
+ font-size: 1.25rem;
869
896
  }
870
897
 
871
- .insights-header {
898
+ .panel-intro {
899
+ color: var(--text-muted);
900
+ font-size: 0.85rem;
901
+ max-width: 62rem;
902
+ }
903
+
904
+ .filter-chips {
872
905
  display: flex;
873
- align-items: baseline;
874
- gap: 0.9rem;
875
906
  flex-wrap: wrap;
907
+ align-items: center;
908
+ gap: 0.4rem;
876
909
  }
877
910
 
878
- .insights-toggle {
911
+ .filter-chips:empty {
912
+ display: none;
913
+ }
914
+
915
+ .chip {
879
916
  display: inline-flex;
880
917
  align-items: center;
881
- gap: 0.45rem;
918
+ gap: 0.35rem;
919
+ max-width: 22rem;
920
+ padding: 0.3rem 0.55rem;
921
+ font-size: 0.8rem;
922
+ font-weight: 600;
923
+ color: var(--primary);
924
+ background: var(--row-selected);
925
+ border: 1px solid var(--border-strong);
926
+ border-radius: 999px;
927
+ cursor: pointer;
928
+ }
929
+
930
+ .chip:hover {
931
+ border-color: var(--primary);
932
+ }
933
+
934
+ .chip-text {
935
+ overflow: hidden;
936
+ text-overflow: ellipsis;
937
+ white-space: nowrap;
938
+ min-width: 0;
939
+ }
940
+
941
+ .link-btn {
942
+ display: inline-flex;
943
+ align-items: center;
944
+ gap: 0.3rem;
945
+ padding: 0;
882
946
  border: none;
883
947
  background: none;
948
+ color: var(--primary);
949
+ font-size: 0.82rem;
950
+ cursor: pointer;
951
+ }
952
+
953
+ .link-btn:hover {
954
+ text-decoration: underline;
955
+ }
956
+
957
+ /* "How these are counted": definitions, folded away until wanted. */
958
+ .metric-help {
959
+ font-size: 0.82rem;
960
+ color: var(--text-muted);
961
+ }
962
+
963
+ .metric-help summary {
884
964
  cursor: pointer;
965
+ width: fit-content;
966
+ font-weight: 600;
967
+ }
968
+
969
+ .metric-help summary:hover {
970
+ color: var(--text);
971
+ }
972
+
973
+ .metric-help dl {
974
+ display: grid;
975
+ grid-template-columns: minmax(8rem, 12rem) minmax(0, 1fr);
976
+ gap: 0.4rem 1rem;
977
+ margin-top: 0.6rem;
978
+ max-width: 62rem;
979
+ }
980
+
981
+ .metric-help dt {
982
+ color: var(--text);
983
+ font-weight: 600;
984
+ }
985
+
986
+ .outcome-legend dt {
987
+ font-weight: 400;
988
+ }
989
+
990
+ /* ---------- Charts ---------- */
991
+
992
+ /* Headline numbers: label, value, change against the period before, sparkline. */
993
+ .stat-tiles {
994
+ display: grid;
995
+ grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr));
996
+ gap: 0.75rem;
997
+ }
998
+
999
+ /* The Overview's nine sit in even rows: all nine, five and four, three by
1000
+ three, or two by two, by the width they have, never seven and two. */
1001
+ .overview-tiles {
1002
+ container: tiles / inline-size;
1003
+ }
1004
+
1005
+ .overview-tiles .stat-tiles {
1006
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1007
+ gap: 0.6rem;
1008
+ }
1009
+
1010
+ @container tiles (width >= 34rem) {
1011
+ .overview-tiles .stat-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
1012
+ }
1013
+
1014
+ @container tiles (width >= 58rem) {
1015
+ .overview-tiles .stat-tiles { grid-template-columns: repeat(5, minmax(0, 1fr)); }
1016
+ }
1017
+
1018
+ @container tiles (width >= 80rem) {
1019
+ .overview-tiles .stat-tiles { grid-template-columns: repeat(9, minmax(0, 1fr)); }
1020
+ }
1021
+
1022
+ .stat-tile {
1023
+ display: flex;
1024
+ flex-direction: column;
1025
+ gap: 0.15rem;
1026
+ min-width: 0;
1027
+ padding: 0.85rem 1rem 0.7rem;
1028
+ font: inherit;
1029
+ color: inherit;
1030
+ text-align: left;
1031
+ background: var(--card-bg);
1032
+ border: 1px solid var(--border);
1033
+ border-radius: var(--radius-small);
1034
+ }
1035
+
1036
+ .stat-tile.selectable {
1037
+ cursor: pointer;
1038
+ transition: border-color 0.15s, box-shadow 0.2s;
1039
+ }
1040
+
1041
+ .stat-tile.selectable:hover {
1042
+ border-color: var(--border-strong);
1043
+ }
1044
+
1045
+ .stat-tile.active {
1046
+ border-color: var(--primary);
1047
+ box-shadow: inset 0 -2px 0 var(--primary), 0 0 12px var(--primary-glow);
1048
+ }
1049
+
1050
+ .stat-label {
1051
+ color: var(--text-muted);
1052
+ font-size: 0.78rem;
1053
+ }
1054
+
1055
+ .stat-value {
1056
+ font-size: 1.55rem;
885
1057
  font-weight: 700;
886
- font-size: 1rem;
887
- padding: 0;
1058
+ letter-spacing: -0.02em;
1059
+ font-variant-numeric: tabular-nums;
1060
+ white-space: nowrap;
1061
+ overflow: hidden;
1062
+ text-overflow: ellipsis;
888
1063
  }
889
1064
 
890
- .insights-caret {
1065
+ .stat-delta {
1066
+ display: flex;
1067
+ align-items: center;
1068
+ flex-wrap: wrap;
1069
+ gap: 0.25rem;
1070
+ min-height: 1.1rem;
1071
+ font-size: 0.74rem;
1072
+ color: var(--text-muted);
1073
+ }
1074
+
1075
+ .stat-delta-value {
1076
+ font-weight: 700;
1077
+ font-variant-numeric: tabular-nums;
1078
+ }
1079
+
1080
+ /* Good and bad changes carry an arrow and a sign as well as a colour. */
1081
+ .stat-delta.tone-good .stat-delta-value,
1082
+ .stat-delta.tone-good .icon {
1083
+ color: var(--success);
1084
+ }
1085
+
1086
+ .stat-delta.tone-bad .stat-delta-value,
1087
+ .stat-delta.tone-bad .icon {
1088
+ color: var(--danger);
1089
+ }
1090
+
1091
+ .stat-delta .icon {
1092
+ width: 0.9rem;
1093
+ height: 0.9rem;
1094
+ }
1095
+
1096
+ .sparkline {
1097
+ display: block;
1098
+ width: 100%;
1099
+ height: 28px;
1100
+ /* On the tile's bottom edge, level across a row whatever the label's length. */
1101
+ margin-top: auto;
1102
+ padding-top: 0.3rem;
1103
+ overflow: visible;
1104
+ }
1105
+
1106
+ .sparkline-line {
1107
+ fill: none;
1108
+ stroke: var(--chart-series);
1109
+ stroke-width: 1.5;
1110
+ stroke-linejoin: round;
1111
+ stroke-linecap: round;
1112
+ }
1113
+
1114
+ .tracking-summary .sparkline {
1115
+ display: none;
1116
+ }
1117
+
1118
+ /* The Overview */
1119
+
1120
+ .overview-content {
1121
+ display: flex;
1122
+ flex-direction: column;
1123
+ gap: 1rem;
1124
+ transition: opacity 0.2s;
1125
+ }
1126
+
1127
+ /* Refetch keeps the frame: the last render stays, dimmed, until the new one. */
1128
+ .overview.loading .overview-content {
1129
+ opacity: 0.55;
1130
+ }
1131
+
1132
+ .overview-span {
891
1133
  color: var(--text-muted);
892
1134
  font-size: 0.8rem;
893
- transition: transform 0.2s;
1135
+ margin-top: -0.4rem;
1136
+ }
1137
+
1138
+ .overview-top {
1139
+ display: grid;
1140
+ grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr);
1141
+ gap: 1rem;
1142
+ }
1143
+
1144
+ .overview-grid {
1145
+ display: grid;
1146
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1147
+ gap: 1rem;
1148
+ container: overview-grid / inline-size;
894
1149
  }
895
1150
 
896
- .insights-toggle[aria-expanded='false'] .insights-caret {
897
- transform: rotate(-90deg);
1151
+ .card-wide {
1152
+ grid-column: 1 / -1;
898
1153
  }
899
1154
 
900
- .insights-caption,
901
- .insights-span {
1155
+ .card-body {
1156
+ min-width: 0;
1157
+ }
1158
+
1159
+ .card-footer {
1160
+ margin-top: auto;
1161
+ }
1162
+
1163
+ .card-note {
902
1164
  color: var(--text-muted);
1165
+ font-size: 0.75rem;
1166
+ }
1167
+
1168
+ .card-note a {
1169
+ color: inherit;
1170
+ }
1171
+
1172
+ .card-note a:hover {
1173
+ color: var(--primary);
1174
+ }
1175
+
1176
+ .chart-card-header .segmented {
1177
+ flex-wrap: wrap;
1178
+ }
1179
+
1180
+ /* Ranked tables: the label with its bar behind it, then the measures. */
1181
+
1182
+ .rank-table {
1183
+ width: 100%;
1184
+ border-collapse: collapse;
1185
+ table-layout: fixed;
903
1186
  font-size: 0.82rem;
904
1187
  }
905
1188
 
906
- .insights-body {
1189
+ .rank-table th,
1190
+ .rank-table td {
1191
+ padding: 0.2rem 0.35rem;
1192
+ text-align: left;
1193
+ font-weight: 400;
1194
+ }
1195
+
1196
+ .rank-table thead th {
1197
+ color: var(--text-muted);
1198
+ font-size: 0.72rem;
1199
+ font-weight: 700;
1200
+ text-transform: uppercase;
1201
+ letter-spacing: 0.04em;
1202
+ padding-bottom: 0.35rem;
1203
+ }
1204
+
1205
+ .rank-table .rank-num {
1206
+ width: 5.2rem;
1207
+ text-align: right;
1208
+ font-variant-numeric: tabular-nums;
1209
+ white-space: nowrap;
1210
+ }
1211
+
1212
+ .rank-table thead .rank-num {
1213
+ white-space: normal;
1214
+ vertical-align: bottom;
1215
+ }
1216
+
1217
+ .rank-label-head {
1218
+ width: auto;
1219
+ vertical-align: bottom;
1220
+ }
1221
+
1222
+ .rank-label {
1223
+ padding-left: 0;
1224
+ }
1225
+
1226
+ .rank-select,
1227
+ .rank-static {
1228
+ position: relative;
907
1229
  display: flex;
908
1230
  flex-direction: column;
909
- gap: 1rem;
1231
+ justify-content: center;
1232
+ width: 100%;
1233
+ min-height: 1.9rem;
1234
+ padding: 0.2rem 1.6rem 0.2rem 0.5rem;
1235
+ font: inherit;
1236
+ color: var(--text);
1237
+ text-align: left;
1238
+ border: none;
1239
+ border-radius: 4px;
1240
+ background: transparent;
1241
+ overflow: hidden;
1242
+ }
1243
+
1244
+ .rank-select {
1245
+ cursor: pointer;
1246
+ }
1247
+
1248
+ .rank-bar {
1249
+ position: absolute;
1250
+ inset: 0 auto 0 0;
1251
+ background: var(--chart-series);
1252
+ opacity: 0.18;
1253
+ border-radius: 4px;
1254
+ pointer-events: none;
1255
+ }
1256
+
1257
+ .rank-text,
1258
+ .rank-detail {
1259
+ position: relative;
1260
+ }
1261
+
1262
+ .rank-detail {
1263
+ color: var(--text-muted);
1264
+ font-size: 0.72rem;
1265
+ }
1266
+
1267
+ .rank-filter-icon {
1268
+ position: absolute;
1269
+ right: 0.4rem;
1270
+ top: 50%;
1271
+ transform: translateY(-50%);
1272
+ color: var(--primary);
1273
+ opacity: 0;
1274
+ transition: opacity 0.15s;
1275
+ }
1276
+
1277
+ .rank-select:hover,
1278
+ .rank-select:focus-visible {
1279
+ background: var(--row-hover);
1280
+ }
1281
+
1282
+ .rank-select:hover .rank-filter-icon,
1283
+ .rank-select:focus-visible .rank-filter-icon {
1284
+ opacity: 1;
1285
+ }
1286
+
1287
+ .rank-muted .rank-static,
1288
+ .rank-muted td {
1289
+ color: var(--text-muted);
1290
+ }
1291
+
1292
+ .rank-muted .rank-bar {
1293
+ background: var(--text-muted);
1294
+ }
1295
+
1296
+ /* Right now */
1297
+
1298
+ .live-card .chart-title {
1299
+ display: inline-flex;
1300
+ align-items: center;
1301
+ gap: 0.5rem;
1302
+ }
1303
+
1304
+ .live-dot {
1305
+ width: 0.55rem;
1306
+ height: 0.55rem;
1307
+ border-radius: 50%;
1308
+ background: var(--success);
1309
+ box-shadow: 0 0 0 0 var(--primary-glow);
1310
+ animation: pulse 2s ease-out infinite;
1311
+ }
1312
+
1313
+ .live-body {
1314
+ display: flex;
1315
+ flex-direction: column;
1316
+ gap: 0.8rem;
1317
+ }
1318
+
1319
+ .live-headline {
1320
+ display: flex;
1321
+ align-items: baseline;
1322
+ gap: 0.6rem;
1323
+ flex-wrap: wrap;
1324
+ }
1325
+
1326
+ .live-count {
1327
+ font-size: 2.2rem;
1328
+ font-weight: 700;
1329
+ letter-spacing: -0.03em;
1330
+ font-variant-numeric: tabular-nums;
1331
+ line-height: 1;
1332
+ }
1333
+
1334
+ .live-caption {
1335
+ color: var(--text-muted);
1336
+ font-size: 0.8rem;
1337
+ }
1338
+
1339
+ .minute-svg {
1340
+ display: block;
1341
+ width: 100%;
1342
+ height: 56px;
1343
+ cursor: crosshair;
1344
+ }
1345
+
1346
+ .minute-svg:focus-visible {
1347
+ outline: 2px solid var(--primary);
1348
+ outline-offset: 3px;
1349
+ border-radius: 4px;
1350
+ }
1351
+
1352
+ .minute-bar {
1353
+ fill: var(--chart-series);
1354
+ }
1355
+
1356
+ .minute-bar.active {
1357
+ fill: var(--primary-bright);
1358
+ }
1359
+
1360
+ .minute-axis {
1361
+ display: flex;
1362
+ justify-content: space-between;
1363
+ color: var(--text-muted);
1364
+ font-size: 0.7rem;
1365
+ margin-top: 0.2rem;
910
1366
  }
911
1367
 
912
- .stat-tiles {
1368
+ /* Weekday by hour */
1369
+
1370
+ .heatmap {
913
1371
  display: grid;
914
- grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
915
- gap: 0.75rem;
1372
+ grid-template-columns: 3rem repeat(24, minmax(0, 1fr));
1373
+ gap: 2px;
1374
+ align-items: center;
916
1375
  }
917
1376
 
918
- .stat-tile {
919
- padding: 0.85rem 1rem;
920
- background: var(--card-bg);
921
- border: 1px solid var(--border);
922
- border-radius: var(--radius-small);
1377
+ .heatmap:focus-visible {
1378
+ outline: 2px solid var(--primary);
1379
+ outline-offset: 4px;
1380
+ border-radius: 4px;
923
1381
  }
924
1382
 
925
- .stat-label {
1383
+ .heatmap-hour,
1384
+ .heatmap-day {
926
1385
  color: var(--text-muted);
927
- font-size: 0.78rem;
1386
+ font-size: 0.68rem;
1387
+ white-space: nowrap;
928
1388
  }
929
1389
 
930
- .stat-value {
931
- font-size: 1.6rem;
932
- font-weight: 700;
933
- letter-spacing: -0.02em;
1390
+ .heatmap-hour {
1391
+ overflow: visible;
934
1392
  }
935
1393
 
936
- .insights-grid {
937
- display: grid;
938
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
939
- gap: 1rem;
1394
+ .heatmap-cell {
1395
+ display: block;
1396
+ height: 1.35rem;
1397
+ border-radius: 3px;
940
1398
  }
941
1399
 
942
- .breakdown-grid {
943
- display: grid;
944
- grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
945
- gap: 1rem;
1400
+ .heatmap-cell.active {
1401
+ outline: 2px solid var(--text);
1402
+ outline-offset: -1px;
1403
+ }
1404
+
1405
+ .heatmap-body {
1406
+ display: flex;
1407
+ flex-direction: column;
1408
+ gap: 0.7rem;
946
1409
  }
947
1410
 
948
1411
  .chart-card {
@@ -1106,39 +1569,40 @@ textarea.input {
1106
1569
  flex-shrink: 0;
1107
1570
  }
1108
1571
 
1109
- /* Breakdown bars and the country list */
1572
+ /* The country list beside the map */
1110
1573
 
1111
- .bar-list,
1112
1574
  .country-list {
1113
1575
  display: flex;
1114
1576
  flex-direction: column;
1115
- gap: 0.35rem;
1577
+ gap: 0.2rem;
1116
1578
  list-style: none;
1117
1579
  }
1118
1580
 
1119
- .bar-row,
1120
1581
  .country-row {
1121
1582
  display: grid;
1122
1583
  grid-template-columns: minmax(0, 7.5rem) minmax(0, 1fr) auto;
1123
1584
  align-items: center;
1124
1585
  gap: 0.6rem;
1586
+ width: 100%;
1587
+ font: inherit;
1125
1588
  font-size: 0.82rem;
1126
- padding: 0.1rem 0.25rem;
1589
+ color: inherit;
1590
+ text-align: left;
1591
+ padding: 0.2rem 0.3rem;
1592
+ border: none;
1127
1593
  border-radius: 4px;
1128
- cursor: default;
1594
+ background: transparent;
1595
+ }
1596
+
1597
+ button.country-row {
1598
+ cursor: pointer;
1129
1599
  }
1130
1600
 
1131
- .bar-row:hover,
1132
- .bar-row:focus-visible,
1133
1601
  .country-row:hover,
1134
1602
  .country-row:focus-visible {
1135
1603
  background: var(--row-hover);
1136
1604
  }
1137
1605
 
1138
- .bar-other .bar-label {
1139
- color: var(--text-muted);
1140
- }
1141
-
1142
1606
  .bar-track {
1143
1607
  display: block;
1144
1608
  height: 10px;
@@ -1152,11 +1616,6 @@ textarea.input {
1152
1616
  border-radius: 0 4px 4px 0;
1153
1617
  }
1154
1618
 
1155
- .bar-other .bar-fill {
1156
- background: var(--text-muted);
1157
- opacity: 0.6;
1158
- }
1159
-
1160
1619
  .bar-value {
1161
1620
  font-variant-numeric: tabular-nums;
1162
1621
  color: var(--text);
@@ -1201,6 +1660,10 @@ textarea.input {
1201
1660
  stroke-width: 2;
1202
1661
  }
1203
1662
 
1663
+ .country.selectable {
1664
+ cursor: pointer;
1665
+ }
1666
+
1204
1667
  .country.active {
1205
1668
  stroke: var(--text);
1206
1669
  stroke-width: 1;
@@ -1384,162 +1847,245 @@ input[type='checkbox'] {
1384
1847
  cursor: pointer;
1385
1848
  }
1386
1849
 
1387
- .col-select { width: 2.6rem; }
1388
- .col-time { width: 8.5rem; }
1389
- .col-app { width: 8rem; }
1390
- .col-page { width: auto; }
1391
- .col-source { width: 9.5rem; }
1392
- .col-device { width: 6.5rem; }
1393
- .col-country { width: 5.5rem; }
1394
- .col-client { width: 8rem; }
1395
- .col-event { width: 7rem; }
1396
- .col-status { width: 10.5rem; }
1397
- .col-actions { width: 8.5rem; }
1398
-
1399
- .log-table .col-action { width: 11rem; }
1400
- .log-table .col-app { width: 9rem; }
1401
- .log-table .col-rows { width: 5rem; }
1402
- .log-table .col-fields { width: auto; }
1403
- .log-table .col-session { width: 7rem; }
1404
- .log-table .col-ip { width: 11rem; }
1405
- .log-table .col-source { width: 8rem; }
1406
- .log-table .col-event { width: 9rem; }
1407
- .log-table .col-unique { width: 6rem; }
1408
- .log-table .col-view { width: auto; }
1409
-
1410
1850
  /*
1411
- * Reflow (FRONTEND.md §12). A table answers to its own width, not the
1412
- * viewport's: it drops optional columns, least useful first, and becomes cards
1413
- * where even its essential columns no longer fit (views below 53rem, logs
1414
- * below 46rem). Each threshold is the width at which the columns still shown
1415
- * leave the flexible column (page, fields, or view) at least 13rem:
1416
- *
1417
- * views, one app always 39.6 (select, time, source, status, actions);
1418
- * + event 7 from 60, country 5.5 from 65.5,
1419
- * device 6.5 from 72, client 8 from 80
1420
- * views, All apps the same plus app 8, so each step comes 8rem later;
1421
- * source also steps aside between the cards and 61
1422
- * admin log always 45.5; + ip 11 from 57, session 7 from 64
1423
- * view log always 40.5; + view ID 12 from 53
1851
+ * Columns (FRONTEND.md §12: reflow, never scroll sideways). dataTable.js
1852
+ * decides which of the admin's chosen columns fit the table's own width, in
1853
+ * their order, and sets their widths; what does not fit is under each row,
1854
+ * behind its "more" button. Below a card's width, each row is a card.
1424
1855
  */
1425
- .table-wrap {
1426
- container: table / inline-size;
1856
+
1857
+ .data-table th {
1858
+ position: relative;
1859
+ overflow: hidden;
1860
+ text-overflow: ellipsis;
1861
+ white-space: nowrap;
1862
+ }
1863
+
1864
+ .th-label {
1865
+ overflow: hidden;
1866
+ text-overflow: ellipsis;
1867
+ }
1868
+
1869
+ /* The draggable right edge of a column header. */
1870
+ .resize-handle {
1871
+ position: absolute;
1872
+ top: 0;
1873
+ right: 0;
1874
+ bottom: 0;
1875
+ width: 11px;
1876
+ cursor: col-resize;
1877
+ touch-action: none;
1427
1878
  }
1428
1879
 
1429
- /* One app's tab already names the app. */
1430
- .views-table:not(.has-app) .col-app {
1880
+ .resize-handle::after {
1881
+ content: '';
1882
+ position: absolute;
1883
+ top: 25%;
1884
+ bottom: 25%;
1885
+ right: 3px;
1886
+ width: 2px;
1887
+ border-radius: 1px;
1888
+ background: var(--border);
1889
+ transition: background-color 0.15s;
1890
+ }
1891
+
1892
+ .resize-handle:hover::after,
1893
+ .resize-handle:focus-visible::after,
1894
+ .resizing-column .resize-handle:active::after {
1895
+ background: var(--primary);
1896
+ }
1897
+
1898
+ .resize-handle:focus-visible {
1899
+ outline: 2px solid var(--primary);
1900
+ outline-offset: -2px;
1901
+ }
1902
+
1903
+ /* While an edge is dragged, the pointer and the text stay put. */
1904
+ body.resizing-column {
1905
+ cursor: col-resize;
1906
+ user-select: none;
1907
+ }
1908
+
1909
+ .data-table .col-select {
1910
+ padding-left: 0.6rem;
1911
+ padding-right: 0.3rem;
1912
+ }
1913
+
1914
+ /* The "more" button's own 28px, and a little air. */
1915
+ .data-table .col-more {
1916
+ padding-left: 0.3rem;
1917
+ padding-right: 0.3rem;
1918
+ }
1919
+
1920
+ .col-actions {
1921
+ white-space: nowrap;
1922
+ }
1923
+
1924
+ /* What did not fit, under its row: labelled, in the admin's order. */
1925
+ .data-table tbody tr.row-more {
1926
+ height: auto;
1927
+ background: var(--code-bg);
1928
+ }
1929
+
1930
+ .data-table tbody tr.row-more:hover {
1931
+ background: var(--code-bg);
1932
+ }
1933
+
1934
+ .more-list {
1935
+ display: grid;
1936
+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
1937
+ gap: 0.6rem 1.25rem;
1938
+ padding: 0.2rem 0 0.3rem 2.4rem;
1939
+ }
1940
+
1941
+ .more-list dt {
1942
+ font-size: 0.68rem;
1943
+ font-weight: 700;
1944
+ text-transform: uppercase;
1945
+ letter-spacing: 0.06em;
1946
+ color: var(--text-muted);
1947
+ }
1948
+
1949
+ .more-list dd {
1950
+ min-width: 0;
1951
+ margin-bottom: 0.1rem;
1952
+ }
1953
+
1954
+ /* Cards: below a table's width, each row stacks its fields, each labelled. */
1955
+ .data-table.cards thead {
1431
1956
  display: none;
1432
1957
  }
1433
1958
 
1434
- @container table (width < 88rem) {
1435
- .views-table.has-app .col-client { display: none; }
1959
+ .data-table.cards,
1960
+ .data-table.cards tbody,
1961
+ .data-table.cards tr,
1962
+ .data-table.cards td {
1963
+ display: block;
1964
+ width: 100%;
1436
1965
  }
1437
1966
 
1438
- @container table (width < 80rem) {
1439
- .views-table .col-client,
1440
- .views-table.has-app .col-device { display: none; }
1967
+ .data-table.cards tbody tr {
1968
+ height: auto;
1969
+ position: relative;
1970
+ padding: 0.6rem 0.8rem;
1971
+ border-bottom: 1px solid var(--border);
1441
1972
  }
1442
1973
 
1443
- @container table (width < 73.5rem) {
1444
- .views-table.has-app .col-country { display: none; }
1974
+ .data-table.cards td {
1975
+ border: none;
1976
+ padding: 0.15rem 0;
1445
1977
  }
1446
1978
 
1447
- @container table (width < 72rem) {
1448
- .views-table .col-device { display: none; }
1979
+ .data-table.cards td[data-label] {
1980
+ display: grid;
1981
+ grid-template-columns: 6.5rem minmax(0, 1fr);
1982
+ column-gap: 0.75rem;
1983
+ /* From the top, not the baseline: a badge's baseline would otherwise make
1984
+ a card with badges a few pixels taller than one without. */
1985
+ align-items: start;
1449
1986
  }
1450
1987
 
1451
- @container table (width < 68rem) {
1452
- .views-table.has-app .col-event { display: none; }
1988
+ .data-table.cards td[data-label]::before {
1989
+ content: attr(data-label);
1990
+ grid-row: 1 / span 2;
1991
+ padding-top: 0.2rem;
1992
+ font-size: 0.68rem;
1993
+ font-weight: 700;
1994
+ text-transform: uppercase;
1995
+ letter-spacing: 0.06em;
1996
+ color: var(--text-muted);
1453
1997
  }
1454
1998
 
1455
- @container table (width < 65.5rem) {
1456
- .views-table .col-country { display: none; }
1999
+ .data-table.cards td[data-label] > * {
2000
+ grid-column: 2;
1457
2001
  }
1458
2002
 
1459
- @container table (width < 64rem) {
1460
- .log-table .col-session { display: none; }
2003
+ /* A badge keeps its own width; only text fills the value column. */
2004
+ .data-table.cards td[data-label] > .badge {
2005
+ justify-self: start;
1461
2006
  }
1462
2007
 
1463
- @container table (53rem <= width < 61rem) {
1464
- .views-table.has-app .col-source { display: none; }
2008
+ /* The left gutter of a views card holds its checkbox. */
2009
+ .views-table.cards tbody tr:not(.row-more) {
2010
+ padding-left: 2.8rem;
1465
2011
  }
1466
2012
 
1467
- @container table (width < 60rem) {
1468
- .views-table .col-event { display: none; }
2013
+ .views-table.cards td.col-select {
2014
+ position: absolute;
2015
+ left: 0.8rem;
2016
+ top: 0.8rem;
2017
+ width: auto;
2018
+ padding: 0;
1469
2019
  }
1470
2020
 
1471
- @container table (width < 57rem) {
1472
- .log-table .col-ip { display: none; }
2021
+ /* A card's status line is full width, so every badge fits on one line;
2022
+ fixed, so a card with no badges is as tall as one with some. */
2023
+ .data-table.cards .badges {
2024
+ height: 1.4rem;
1473
2025
  }
1474
2026
 
1475
- @container table (width < 53rem) {
1476
- .log-table .col-view {
1477
- display: none;
1478
- }
2027
+ .data-table.cards .col-more .btn {
2028
+ padding-left: 0;
2029
+ }
1479
2030
 
1480
- .views-table thead {
1481
- display: none;
1482
- }
2031
+ .data-table.cards tr.row-more {
2032
+ padding-top: 0;
2033
+ }
1483
2034
 
1484
- .views-table,
1485
- .views-table tbody,
1486
- .views-table tr,
1487
- .views-table td {
1488
- display: block;
1489
- width: 100%;
1490
- }
2035
+ .data-table.cards .more-list {
2036
+ grid-template-columns: 6.5rem minmax(0, 1fr);
2037
+ column-gap: 0.75rem;
2038
+ row-gap: 0.3rem;
2039
+ padding: 0;
2040
+ }
1491
2041
 
1492
- /* The left gutter holds the row's checkbox. */
1493
- .views-table tbody tr {
1494
- height: auto;
1495
- position: relative;
1496
- padding: 0.6rem 0.8rem 0.6rem 2.8rem;
1497
- border-bottom: 1px solid var(--border);
1498
- }
2042
+ .views-table.cards .more-list {
2043
+ padding-left: 2rem;
2044
+ }
1499
2045
 
1500
- .views-table td {
1501
- border: none;
1502
- padding: 0.15rem 0;
1503
- }
2046
+ /* Choosing columns: a checkbox and two move buttons per column. */
2047
+ .column-chooser {
2048
+ list-style: none;
2049
+ display: flex;
2050
+ flex-direction: column;
2051
+ gap: 0.15rem;
2052
+ margin-bottom: 0.75rem;
2053
+ }
1504
2054
 
1505
- .views-table td.col-select {
1506
- position: absolute;
1507
- left: 0.8rem;
1508
- top: 0.8rem;
1509
- width: auto;
1510
- }
2055
+ .column-choice {
2056
+ display: grid;
2057
+ grid-template-columns: minmax(0, 1fr) auto auto;
2058
+ align-items: center;
2059
+ gap: 0.25rem;
2060
+ padding: 0.15rem 0.4rem;
2061
+ border-radius: 6px;
2062
+ }
1511
2063
 
1512
- /* A card's status line is full width, so every badge fits on one line;
1513
- fixed, so a card with no badges is as tall as one with some. */
1514
- .views-table .badges {
1515
- height: 1.4rem;
1516
- }
2064
+ .column-choice:hover {
2065
+ background: var(--row-hover);
1517
2066
  }
1518
2067
 
1519
- /* Log rows have no checkbox, so their cards need no gutter. */
1520
- @container table (width < 46rem) {
1521
- .log-table thead {
1522
- display: none;
1523
- }
2068
+ .column-choice-label {
2069
+ display: flex;
2070
+ align-items: center;
2071
+ gap: 0.6rem;
2072
+ min-width: 0;
2073
+ padding: 0.3rem 0;
2074
+ cursor: pointer;
2075
+ }
1524
2076
 
1525
- .log-table,
1526
- .log-table tbody,
1527
- .log-table tr,
1528
- .log-table td {
1529
- display: block;
1530
- width: 100%;
1531
- }
2077
+ .column-choice-note {
2078
+ color: var(--text-muted);
2079
+ font-size: 0.78rem;
2080
+ }
1532
2081
 
1533
- .log-table tbody tr {
1534
- height: auto;
1535
- padding: 0.6rem 0.8rem;
1536
- border-bottom: 1px solid var(--border);
1537
- }
2082
+ .icon-btn:disabled {
2083
+ opacity: 0.3;
2084
+ pointer-events: none;
2085
+ }
1538
2086
 
1539
- .log-table td {
1540
- border: none;
1541
- padding: 0.15rem 0;
1542
- }
2087
+ .toolbar-slot {
2088
+ display: inline-flex;
1543
2089
  }
1544
2090
 
1545
2091
  .cell-primary {
@@ -1597,6 +2143,7 @@ input[type='checkbox'] {
1597
2143
  white-space: nowrap;
1598
2144
  }
1599
2145
 
2146
+ .badge-success { color: var(--success); background: var(--row-selected); }
1600
2147
  .badge-danger { color: var(--danger); background: var(--danger-bg); }
1601
2148
  .badge-warning { color: var(--warning); background: var(--warning-bg); }
1602
2149
  .badge-info { color: var(--info); background: var(--info-bg); }
@@ -1621,6 +2168,64 @@ input[type='checkbox'] {
1621
2168
  font-size: 0.85rem;
1622
2169
  }
1623
2170
 
2171
+ /* ---------- Footer ---------- */
2172
+
2173
+ .app-footer {
2174
+ max-width: 1920px;
2175
+ margin: 0 auto;
2176
+ padding: 1.5rem 2rem 2.5rem;
2177
+ border-top: 1px solid var(--border);
2178
+ color: var(--text-muted);
2179
+ font-size: 0.8rem;
2180
+ display: flex;
2181
+ flex-direction: column;
2182
+ gap: 0.6rem;
2183
+ }
2184
+
2185
+ .footer-row {
2186
+ display: flex;
2187
+ flex-wrap: wrap;
2188
+ align-items: center;
2189
+ justify-content: space-between;
2190
+ gap: 0.75rem 1.5rem;
2191
+ }
2192
+
2193
+ .footer-copyright {
2194
+ color: var(--text);
2195
+ }
2196
+
2197
+ .footer-links {
2198
+ display: flex;
2199
+ flex-wrap: wrap;
2200
+ gap: 0.4rem 1.1rem;
2201
+ }
2202
+
2203
+ .footer-link {
2204
+ display: inline-flex;
2205
+ align-items: center;
2206
+ gap: 0.35rem;
2207
+ color: var(--text-muted);
2208
+ text-decoration: none;
2209
+ }
2210
+
2211
+ .footer-link:hover {
2212
+ color: var(--primary);
2213
+ }
2214
+
2215
+ .footer-legal {
2216
+ font-size: 0.75rem;
2217
+ }
2218
+
2219
+ .footer-copyright a,
2220
+ .footer-credits a {
2221
+ color: inherit;
2222
+ }
2223
+
2224
+ .footer-copyright a:hover,
2225
+ .footer-credits a:hover {
2226
+ color: var(--primary);
2227
+ }
2228
+
1624
2229
  /* ---------- Modal (FRONTEND.md §1.1) ---------- */
1625
2230
 
1626
2231
  #modal-root {
@@ -1678,6 +2283,20 @@ input[type='checkbox'] {
1678
2283
  padding: 1rem 1.5rem 1.4rem;
1679
2284
  }
1680
2285
 
2286
+ .detail-groups {
2287
+ display: flex;
2288
+ flex-direction: column;
2289
+ gap: 1.1rem;
2290
+ }
2291
+
2292
+ .detail-group-title {
2293
+ font-size: 0.75rem;
2294
+ text-transform: uppercase;
2295
+ letter-spacing: 0.06em;
2296
+ color: var(--secondary);
2297
+ margin-bottom: 0.45rem;
2298
+ }
2299
+
1681
2300
  .detail-list {
1682
2301
  display: grid;
1683
2302
  grid-template-columns: 11rem 1fr;
@@ -1782,6 +2401,12 @@ input[type='checkbox'] {
1782
2401
  50% { opacity: 0; }
1783
2402
  }
1784
2403
 
2404
+ @keyframes pulse {
2405
+ 0% { box-shadow: 0 0 0 0 var(--primary-glow); }
2406
+ 70% { box-shadow: 0 0 0 0.45rem transparent; }
2407
+ 100% { box-shadow: 0 0 0 0 transparent; }
2408
+ }
2409
+
1785
2410
  @keyframes fade-in {
1786
2411
  from { opacity: 0; }
1787
2412
  }
@@ -1809,15 +2434,34 @@ input[type='checkbox'] {
1809
2434
 
1810
2435
  /* ---------- Narrow screens (FRONTEND.md §12: reflow, never scroll sideways) ---------- */
1811
2436
 
1812
- @media (max-width: 1180px) {
1813
- .insights-grid {
2437
+ /* The Overview reflows by its own width: two columns of cards, then one. */
2438
+ @container overview-grid (width < 56rem) {
2439
+ .overview-grid > .chart-card {
2440
+ grid-column: 1 / -1;
2441
+ }
2442
+ }
2443
+
2444
+ @media (max-width: 1100px) {
2445
+ .overview-top {
2446
+ grid-template-columns: minmax(0, 1fr);
2447
+ }
2448
+
2449
+ .overview-grid {
1814
2450
  grid-template-columns: minmax(0, 1fr);
1815
2451
  }
1816
2452
  }
1817
2453
 
1818
2454
  @media (max-width: 960px) {
1819
2455
  main {
1820
- padding: 1.25rem 1rem 5rem;
2456
+ padding: 1.25rem 1rem 3rem;
2457
+ }
2458
+
2459
+ .app-footer {
2460
+ padding: 1.25rem 1rem 2rem;
2461
+ }
2462
+
2463
+ .header-link span {
2464
+ display: none;
1821
2465
  }
1822
2466
 
1823
2467
  .topbar {
@@ -1869,7 +2513,56 @@ input[type='checkbox'] {
1869
2513
  grid-template-columns: 1fr;
1870
2514
  }
1871
2515
 
1872
- .logo-text {
2516
+ .metric-help dl {
2517
+ grid-template-columns: 1fr;
2518
+ }
2519
+
2520
+ .logo-text,
2521
+ .version-badge {
2522
+ display: none;
2523
+ }
2524
+
2525
+ .topbar {
2526
+ gap: 0.5rem;
2527
+ }
2528
+
2529
+ .topbar-actions {
2530
+ gap: 0.4rem;
2531
+ }
2532
+
2533
+ /* Sign out keeps its name for assistive technology, and shows its icon. */
2534
+ #logout-btn {
2535
+ padding: 0.4rem 0.55rem;
2536
+ }
2537
+
2538
+ #logout-btn span {
2539
+ position: absolute;
2540
+ width: 1px;
2541
+ height: 1px;
2542
+ overflow: hidden;
2543
+ clip: rect(0 0 0 0);
2544
+ white-space: nowrap;
2545
+ }
2546
+
2547
+ .rank-table .rank-share {
1873
2548
  display: none;
1874
2549
  }
2550
+
2551
+ .rank-table .rank-num {
2552
+ width: 4.2rem;
2553
+ }
2554
+
2555
+ /* Hours stay readable: every sixth is labelled, cells get shorter. */
2556
+ .heatmap {
2557
+ grid-template-columns: 2.2rem repeat(24, minmax(0, 1fr));
2558
+ gap: 1px;
2559
+ }
2560
+
2561
+ .heatmap-cell {
2562
+ height: 1rem;
2563
+ }
2564
+
2565
+ .heatmap-hour:nth-child(6n + 5) {
2566
+ visibility: hidden;
2567
+ }
1875
2568
  }