1jQuery(document).ready(function($) { 2 3 'use strict'; 4 5 6 7 // First check if Vue is available 8 9 if (typeof Vue === 'undefined') { 10 11 console.error('Vue is not defined. Make sure Vue is loaded before this script.'); 12 13 return; 14 15 } 16 17 18 19 if (typeof EscribeAgendaData === 'undefined') { 20 21 console.error('eScribe: shared data helpers are not loaded.'); 22 23 return; 24 25 } 26 27 28 29 // Initialize a separate Vue app for each eScribe widget on the page 30 31 const escribeMounts = document.querySelectorAll('.escribe-mount'); 32 33 escribeMounts.forEach(function(mountEl) { 34 35 const instanceId = mountEl.dataset.instanceId; 36 37 const carouselSel = '#escribeCarousel_' + instanceId; 38 39 const pastModalSel = '#eScribePastModal_' + instanceId; 40 41 const searchModalSel = '#eScribeSearchModal_' + instanceId; 42 43 const searchModalId = 'eScribeSearchModal_' + instanceId; 44 45 const docsModalSel = '#eScribeDocsModal_' + instanceId; 46 47 const docsModalId = 'eScribeDocsModal_' + instanceId; 48 49 const resizeNs = '.escribeResize_' + instanceId; 50 51 // Per-instance, not window-global: two carousels on one page would 52 // otherwise clobber each other's debounce timer. 53 54 let carouselResizeTimer = null; 55 56 let lastCarouselWidth = window.innerWidth; 57 58 59 60 const app = Vue.createApp({ 61 62 data() { 63 64 return { 65 66 instanceId: instanceId, 67 68 loading: false, 69 70 loadError: '', 71 72 searchInitialized: true, // NEW: Always keep search visible regardless of loading state 73 74 raw: {}, 75 76 meetings: { 77 78 MeetingGetByQueryResult: { 79 80 Agendas: { 81 82 'Agenda.Record': [] 83 84 }, 85 86 FutureMeetings: { 87 88 'Meeting.Record': [] 89 90 }, 91 92 PastMeetings: { 93 94 'Meeting.Record': [] 95 96 } 97 98 } 99 100 }, 101 102 emeetings: { 103 104 past: [], 105 106 future: [] 107 108 }, 109 110 documentNames: {}, // Document.Record Id -> document name 111 112 selectedDocuments: [], // Supplemental docs shown in the documents modal 113 114 selectedDocumentsTitle: '', 115 116 meetingTypeFilter: [], // Array of meeting type IDs to filter by 117 118 years: [], 119 120 selectedYear: null, 121 122 selectedMonth: null, 123 124 searchText: '', 125 126 searching: false, 127 128 searchResults: [], 129 130 showSearchResults: false, 131 132 currentSearchPage: 1, 133 134 searchPageSize: 10, 135 136 totalSearchResults: 0, 137 138 totalSearchPages: 1 139 140 }; 141 142 }, 143 144 methods: { 145 146 getType(Id) { 147 148 let name = ''; 149 150 const meetingTypes = this.meetings.MeetingGetByQueryResult.MeetingTypes['MeetingType.Record']; 151 152 153 154 if (meetingTypes) { 155 156 $.each(meetingTypes, function(k, v) { 157 158 if (v.Id.Id === Id) { 159 160 name = v.Name.KeyValueOfintstring[1].Value; 161 162 return false; 163 164 } 165 166 }); 167 168 } 169 170 171 172 return name; 173 174 }, 175 176 // Meeting/document resolution lives in escribe-agenda-data.js so the 177 // carousel and the calendar cannot drift apart. These stay as 178 // methods because the templates call them directly. 179 180 asArray(value) { 181 182 return EscribeAgendaData.asArray(value); 183 184 }, 185 186 dataIndex() { 187 188 // Rebuilt when the payload changes; cheap enough to memoise 189 // against the current result object. 190 191 const result = this.meetings.MeetingGetByQueryResult; 192 193 if (!this._idx || this._idxFor !== result) { 194 195 this._idx = EscribeAgendaData.index(result); 196 197 this._idxFor = result; 198 199 } 200 201 return this._idx; 202 203 }, 204 205 fileStreamUrl(documentId) { 206 207 return EscribeAgendaData.fileStreamUrl(this.dataIndex(), documentId); 208 209 }, 210 211 meetingUrl(meetingId) { 212 213 return EscribeAgendaData.meetingUrl(this.dataIndex(), meetingId); 214 215 }, 216 217 buildDocumentNames(result) { 218 219 this._idx = EscribeAgendaData.index(result); 220 221 this._idxFor = result; 222 223 this.documentNames = this._idx.documentNames; 224 225 }, 226 227 documentName(pkg) { 228 229 return EscribeAgendaData.documentName(this.dataIndex(), pkg); 230 231 }, 232 233 getPackage(meeting) { 234 235 const agenda = EscribeAgendaData.getAgenda(this.dataIndex(), meeting); 236 237 if (agenda) { 238 239 meeting.Link = this.meetingUrl(meeting.Id); 240 241 meeting.PDF = this.fileStreamUrl(agenda.DocumentId); 242 243 } 244 245 return agenda; 246 247 }, 248 249 getMinutes(meeting) { 250 251 const minutes = EscribeAgendaData.getMinutes(this.dataIndex(), meeting); 252 253 if (minutes) { 254 255 meeting.MinuteLink = this.fileStreamUrl(minutes.DocumentId); 256 257 } 258 259 return minutes; 260 261 }, 262 263 getSupplementalDocuments(meeting) { 264 265 return EscribeAgendaData.getSupplementalDocuments(this.dataIndex(), meeting, meeting.Minutes); 266 267 }, 268 269 showDocuments(meeting) { 270 271 this.selectedDocuments = meeting.Documents || []; 272 273 this.selectedDocumentsTitle = this.getType(meeting.MeetingTypeId) + ' â ' + this.niceDate(meeting.Start); 274 275 const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById(docsModalId)); 276 277 modal.show(); 278 279 }, 280 281 GetData() { 282 283 this.loading = true; 284 285 286 287 // Create URL parameters 288 289 const params = new URLSearchParams(); 290 291 params.append('action', 'geteScribeMeetings'); 292 293 294 295 // Always include the validate parameter 296 297 // (it won't be checked for non-logged in users with our updated PHP) 298 299 params.append('validate', eScribeajaxObj.validate); 300 301 302 303 axios.post(eScribeajaxObj.ajax_url, params) 304 305 .then(response => this.loadFields(response)) 306 307 .catch(error => { 308 309 console.error('Error fetching data:', error); 310 311 this.loadError = 'Meeting information is temporarily unavailable.'; 312 313 this.loading = false; 314 315 }); 316 317 }, 318 319 rebuildObject(meeting) { 320 321 meeting.Link = '#'; 322 323 meeting.PDF = '#'; 324 325 meeting.MinuteLink = '#'; 326 327 meeting.Name = this.getType(meeting.MeetingTypeId); 328 329 meeting.Agenda = this.getPackage(meeting); 330 331 meeting.Minutes = this.getMinutes(meeting); 332 333 // Must run after getMinutes(): it excludes the minutes PDF. 334
335 meeting.Documents = this.getSupplementalDocuments(meeting); 336 337 }, 338 339 getYear(value) { 340 341 return new Date(value).getFullYear(); 342 343 }, 344 345 getMonth(value) { 346 347 return new Date(value).getMonth() + 1; 348 349 }, 350 351 niceDate(value) { 352 353 return new Date(value).toLocaleDateString("en-US", { 354 355 weekday: 'long', 356 357 year: 'numeric', 358 359 month: 'long', 360 361 day: 'numeric', 362 363 hour12: true, 364 365 hour: 'numeric', 366 367 minute: '2-digit' 368 369 }); 370 371 }, 372 373 loadFields(response) { 374 375 if (response == null || typeof response.data.error !== "undefined") { 376 377 console.error('Error in response:', response?.data?.error); 378 379 // The plugin no longer halts the page on a service 380 // failure, so say something rather than render nothing. 381 382 this.loadError = (response && response.data && response.data.message) 383 384 || 'Meeting information is temporarily unavailable.'; 385 386 this.loading = false; 387 388 return; 389 390 } 391 392 393 394 this.loadError = ''; 395 396 397 398 this.meetings = response.data; 399 400 401 402 // Check if the expected data structure exists 403 404 if (!response.data.MeetingGetByQueryResult) { 405 406 console.error('Unexpected response structure:', response.data); 407 408 this.loading = false; 409 410 return; 411 412 } 413 414 415 416 // Must run before rebuildObject(): getMinutes() resolves 417 // document names through this lookup. 418 419 // Must read through this.meetings, not response.data: Vue 420 // wraps the assigned object, so response.data.X and 421 // this.meetings.X are different references and the memo in 422 // dataIndex() would miss and rebuild the whole index. 423 424 this.buildDocumentNames(this.meetings.MeetingGetByQueryResult); 425 426 427 428 this.emeetings.past = response.data.MeetingGetByQueryResult.PastMeetings['Meeting.Record'] || []; 429 430 this.emeetings.future = response.data.MeetingGetByQueryResult.FutureMeetings['Meeting.Record'] || []; 431 432 //console.log(this.emeetings.future); 433 434 this.emeetings.future.sort((a, b) => { 435 436 return new Date(a.Start) - new Date(b.Start); 437 438 }); 439 440 441 442 this.emeetings.future.sort((a, b) => { 443 444 return new Date(a.Start) - new Date(b.Start); 445 446 }); 447 448 //console.log(this.emeetings.future); 449 450 // Process future meetings 451 452 for (let i = 0; i < this.emeetings.future.length; i++) { 453 454 this.rebuildObject(this.emeetings.future[i]); 455 456 } 457 458 459 460 // Sort past meetings by date (newest first) 461 462 this.emeetings.past.sort((a, b) => { 463 464 return new Date(b.Start) - new Date(a.Start); 465 466 }); 467 468 469 470 // Process past meetings and collect years 471 472 for (let i = 0; i < this.emeetings.past.length; i++) { 473 474 const meeting = this.emeetings.past[i]; 475 476 this.rebuildObject(meeting); 477 478 479 480 const year = this.getYear(meeting.Start); 481 482 meeting.year = year; 483 484 485
486 const month = this.getMonth(meeting.Start); 487 488 meeting.month = month; 489 490 491 492 if (!this.years.includes(year)) { 493 494 this.years.push(year); 495 496 } 497 498 } 499 500 501 502 // Sort years in descending order (latest to oldest) 503 504 this.years.sort((a, b) => b - a); 505 506 507 508 // Set selected year and month to most recent past meeting 509 510 this.setMostRecentPastDate(); 511 512 513 514 this.loading = false; 515 516 517 518 this.$nextTick(() => { 519 520 this.initTooltips(); 521 522 }); 523 524 525 526 // Initialize carousel after data is loaded 527 528 if (!this.loading) { 529 530 setTimeout(() => { 531 532 this.SetCarousel(); 533 534 }, 1000); 535 536 } 537 538 }, 539 540 loadPast() { 541 542 this.$nextTick(() => { 543 544 this.initTooltips(); 545 546 this.$nextTick(() => { 547 548 // First dispose any existing tooltips in the modal 549 550 var modalTooltips = [].slice.call(document.querySelectorAll(pastModalSel + ' [data-bs-toggle="tooltip"]')); 551 552 modalTooltips.forEach(function (tooltipEl) { 553 554 let tooltip = bootstrap.Tooltip.getInstance(tooltipEl); 555 556 if (tooltip) { 557 558 tooltip.dispose(); 559 560 } 561 562 }); 563 564 }); 565 566 567 568 }); 569 570 }, 571 572 setMostRecentPastDate() { 573 574 // Get meetings filtered by meeting type only 575 576 let meetings = this.emeetings.past; 577 578 579 580 if (this.meetingTypeFilter && this.meetingTypeFilter.length > 0) { 581 582 meetings = this.emeetings.past.filter(meeting => { 583 584 const meetingTypeId = parseInt(meeting.MeetingTypeId); 585 586 return this.meetingTypeFilter.includes(meetingTypeId); 587 588 }); 589 590 } 591 592 593 594 // If we have filtered meetings, set to the most recent one 595 596 if (meetings.length > 0) { 597 598 // Meetings are already sorted by date (most recent first) 599 600 const mostRecent = meetings[0]; 601 602 this.selectedYear = mostRecent.year; 603 604 this.selectedMonth = mostRecent.month; 605 606 } else if (this.filteredYears.length > 0) { 607 608 // No meetings found, but we have years - set to first available year 609 610 this.selectedYear = this.filteredYears[0]; 611 612 // Set month to 1 (January) as a fallback 613 614 this.selectedMonth = 1; 615 616 } 617 618 }, 619 620 SetCarousel() { 621 622 var multipleCardCarousel = document.querySelector(carouselSel); 623 624 if (!multipleCardCarousel) return; 625 626 627 628 // First, remove any previous event handlers and carousel initialization 629 630 $(carouselSel + " .carousel-control-next, " + carouselSel + " .carousel-control-prev").off("click"); 631 632 633 634 try { 635 636 // Destroy existing carousel if it exists 637 638 var existingCarousel = bootstrap.Carousel.getInstance(multipleCardCarousel); 639 640 if (existingCarousel) { 641 642 existingCarousel.dispose(); 643 644 } 645 646 } catch(e) { 647 648 // Ignore errors if carousel wasn't initialized 649 650 } 651 652 653 654 // Handle the different view modes based on screen width 655 656 if (window.matchMedia("(max-width: 575px)").matches) { 657 658 // Mobile view (1 card) 659 660 661 662 // Reset any custom styles 663 664 $(carouselSel + " .carousel-inner").css({ 665 666 "display": "block", 667 668 "overflow": "" 669 670 }); 671 672 $(carouselSel + " .carousel-inner").scrollLeft(0); 673 674 675 676 // Make only first item active, remove active class from others 677 678 $(carouselSel + " .carousel-item").removeClass("active"); 679 680 $(carouselSel + " .carousel-item:first").addClass("active"); 681 682 683 684 // Enable standard Bootstrap carousel with slide 685 686 $(multipleCardCarousel).addClass("slide"); 687 688 689 690 // Initialize Bootstrap carousel 691 692 var mobileCarousel = new bootstrap.Carousel(multipleCardCarousel, { 693 694 interval: false 695 696 }); 697 698 $(carouselSel + " .carousel-control-prev").on("click", function () { 699 700 mobileCarousel.prev(); 701 702 }); 703 704 $(carouselSel + " .carousel-control-next").on("click", function () { 705 706 mobileCarousel.next(); 707 708 }); 709 710 } 711 712 else { 713 714 // Tablet/Desktop view (2 or 3 cards) 715 716 $(multipleCardCarousel).removeClass("slide"); 717 718 719 720 // Make all items active for the multi-card display 721 722 $(carouselSel + " .carousel-item").addClass("active"); 723 724 725 726 // Setup custom horizontal scrolling 727 728 $(carouselSel + " .carousel-inner").css({ 729 730 "display": "flex", 731 732 "overflow": "hidden" 733 734 }); 735 736 737
738 var carouselWidth = $(carouselSel + " .carousel-inner")[0].scrollWidth; 739 740 var cardWidth = $(carouselSel + " .carousel-item").first().width(); 741 742 743 744 var scrollPosition = 0; 745 746 747 748 // Always scroll 1 card at a time for better control 749 750 var scrollCards = 1; 751 752 753 754 $(carouselSel + " .carousel-control-next").on("click", function () { 755 756 if (scrollPosition < carouselWidth - cardWidth * 3) { 757 758 scrollPosition += cardWidth * scrollCards; 759 760 $(carouselSel + " .carousel-inner").animate( 761 762 { scrollLeft: scrollPosition }, 763 764 600 765 766 ); 767 768 } 769 770 }); 771 772 773 774 $(carouselSel + " .carousel-control-prev").on("click", function () { 775 776 if (scrollPosition > 0) { 777 778 scrollPosition -= cardWidth * scrollCards; 779 780 $(carouselSel + " .carousel-inner").animate( 781 782 { scrollLeft: scrollPosition }, 783 784 600 785 786 ); 787 788 } 789 790 }); 791 792 } 793 794 795 796 // Update carousel when window is resized 797 798 const self = this; // Preserve 'this' context 799 800 // Only WIDTH changes matter here â the layout is chosen from 801 // width breakpoints. Re-running on height changes caused a 802 // feedback loop with any other widget that resizes with the 803 // page: relayout -> page height changes -> scrollbar toggles 804 // -> window resize -> relayout, forever. 805 806 lastCarouselWidth = window.innerWidth; 807 808 $(window).off('resize' + resizeNs).on('resize' + resizeNs, function() { 809 810 if (window.innerWidth === lastCarouselWidth) { 811 812 return; 813 814 } 815 816 lastCarouselWidth = window.innerWidth; 817 818 // Wait a moment for the resize to complete before recalculating 819 820 clearTimeout(carouselResizeTimer); 821 822 carouselResizeTimer = setTimeout(function() { 823 824 self.SetCarousel(); 825 826 }, 250); 827 828 }); 829 830 }, 831 832 833 834 performSearch(page = 1) { 835 836 if (!this.searchText || this.searchText.trim() === '') { 837 838 return; 839 840 } 841 842 843 844 if (page && typeof page === 'object' && page.target) { 845 846 // Handle event object - try to extract page number if possible 847 848 const targetValue = page.target.textContent || page.target.value; 849 850 page = parseInt(targetValue, 10); 851 852 if (isNaN(page)) { 853 854 page = 1; // Default to page 1 if we couldn't extract a valid number 855 856 } 857 858 } 859 860 861 862 this.searching = true; 863 864 this.showSearchResults = false; 865 866 this.currentSearchPage = page; 867 868 869 870 // Calculate start index for pagination 871 872 const startIndex = (page - 1) * this.searchPageSize; 873 874 875 876 // Create URL parameters 877 878 const params = new URLSearchParams(); 879 880 params.append('action', 'eScribeMeetingsSearch'); 881 882 params.append('searchText', this.searchText); 883 884 params.append('Page', startIndex); 885 886 params.append('Length', this.searchPageSize); 887 888 889 890 // Always include the validate parameter 891 892 params.append('validate', eScribeajaxObj.validate); 893 894 895 896 axios.post(eScribeajaxObj.ajax_url, params) 897 898 .then(response => { 899 900 this.searching = false; 901 902 if (response.data && response.data.aaData) { 903 904 // Store total counts for pagination 905 906 this.totalSearchResults = response.data.iTotalRecords || 0; 907 908 this.totalSearchPages = Math.ceil(this.totalSearchResults / this.searchPageSize); 909 910 911 912 // Parse the search results 913 914 this.searchResults = this.parseMeetingData(response.data); 915 916 this.showSearchResults = true; 917 918 919 920 // Open the modal to show results if not already open 921 922 if (!$(searchModalSel).hasClass('show')) { 923 924 const searchModal = new bootstrap.Modal(document.getElementById(searchModalId)); 925 926 searchModal.show(); 927 928 } 929 930 } else { 931 932 console.error('Unexpected search response format:', response.data); 933 934 // Show a message to the user 935 936 alert('No search results found or unexpected response format.'); 937 938 } 939 940 }) 941 942 .catch(error => { 943 944 console.error('Error performing search:', error); 945 946 this.searching = false; 947 948 // Surface the server's own message (rate limited, 949 // service unavailable) rather than a generic one. 950 951 const message = error.response && error.response.data && error.response.data.message; 952 953 alert(message || 'Error performing search. Please try again later.'); 954 955 }); 956 957 }, 958 959 960 961 changePageSize() { 962 963 // Reset to first page when changing page size 964 965 this.currentSearchPage = 1; 966 967 this.performSearch(1); 968 969 }, 970 971 972 973 goToSearchPage(page) { 974 975 if (page < 1 || page > this.totalSearchPages) { 976 977 return; 978 979 } 980 981 this.performSearch(page); 982 983 }, 984 985 986 987 // Method to parse search results 988 989 parseMeetingData(jsonData) { 990 991 const results = []; 992 993 994 995 if (jsonData.aaData && Array.isArray(jsonData.aaData)) { 996 997 for (const htmlString of jsonData.aaData) { 998 999 // Each aaData entry is a *group* of documents that 1000 // share a meeting, not a single document, so one 1001 // entry can expand to several rows. 1002 1003 results.push(...this.extractInfoFromHTML(htmlString)); 1004 1005 } 1006 1007 } 1008 1009 1010 1011 return results; 1012 1013 }, 1014 1015 1016 1017 // Vue does not block javascript: URLs in :href bindings, and 1018 // these come from HTML the eSCRIBE portal returns. Only allow 1019 // links we would actually navigate to. 1020 1021 safeUrl(url) { 1022 1023 if (!url) { 1024 1025 return '#'; 1026 1027 } 1028 1029 try { 1030 1031 const parsed = new URL(url, window.location.origin); 1032 1033 return (parsed.protocol === 'http:' || parsed.protocol === 'https:') ? parsed.href : '#'; 1034 1035 } catch (e) { 1036 1037 return '#'; 1038 1039 } 1040 1041 }, 1042 1043 // Method to extract information from HTML strings in search results 1044 1045 extractInfoFromHTML(htmlString) { 1046 1047 const text = el => (el ? el.textContent.trim() : ''); 1048 1049 const doc = new DOMParser().parseFromString(htmlString, 'text/html'); 1050 1051 1052 1053 // Group header row: meeting type and meeting date. 1054 1055 const groupRow = doc.querySelector('.srch-grp-row'); 1056 1057 const groupCells = groupRow ? groupRow.querySelectorAll('td') : []; 1058 1059 const meetingType = text(groupCells[0]) || 'Unknown meeting type'; 1060 1061 const meetingDate = text(groupCells[1]) || 'Unknown date'; 1062 1063 1064 1065 const results = []; 1066 1067 1068 1069 doc.querySelectorAll('.srch-item').forEach(item => { 1070 1071 const link = item.querySelector('a'); 1072
1073 const row = item.closest('tr'); 1074 1075 const cells = row ? row.querySelectorAll('td') : []; 1076 1077 1078 1079 results.push({ 1080 1081 // The title attribute carries the full document 1082 // name; the link text is truncated for display. 1083 1084 title: (link && link.getAttribute('title')) || text(link) || 'No title found', 1085 1086 url: this.safeUrl(link && link.getAttribute('href')), 1087 1088 docType: text(cells[cells.length - 1]) || 'Unknown document', 1089 1090 meetingDate, 1091 1092 meetingType 1093 1094 }); 1095 1096 }); 1097 1098 1099 1100 return results; 1101 1102 }, 1103 1104 clearSearch() { 1105 1106 this.searchText = ''; 1107 1108 this.searchResults = []; 1109 1110 this.showSearchResults = false; 1111 1112 this.currentSearchPage = 1; 1113 1114 this.totalSearchResults = 0; 1115 1116 this.totalSearchPages = 1; 1117 1118 this.clearBackdrops(); 1119 1120 }, 1121 1122 clearBackdrops() { 1123 1124 // Keep one backdrop per modal that is still open and strip 1125 // only the excess. Removing them all would leave a stacked 1126 // modal (documents opened over past meetings) with no 1127 // backdrop once the top one closes. 1128 1129 const openModals = $('.modal.show').length; 1130 1131 $('.modal-backdrop').slice(openModals).remove(); 1132 1133 1134 1135 // Remove modal-open class from body and restore scrolling 1136 1137 if (!openModals) { 1138 1139 $('body').removeClass('modal-open'); 1140 1141 $('body').css({ 1142 1143 'overflow': '', 1144 1145 'padding-right': '' 1146 1147 }); 1148 1149 } 1150 1151 }, 1152 1153 setupModalEvents() { 1154 1155 // Handle search modal hidden event (when modal is fully closed) 1156 1157 $(searchModalSel).on('hidden.bs.modal', () => { 1158 1159 // Remove any lingering backdrop elements 1160 1161 this.clearBackdrops(); 1162 1163 }); 1164 1165 1166 1167 // Handle past meetings modal hidden event (when modal is fully closed) 1168 1169 $(pastModalSel).on('hidden.bs.modal', () => { 1170 1171 // Remove any lingering backdrop elements 1172 1173 this.clearBackdrops(); 1174 1175 }); 1176 1177 1178 1179 // Backup cleanup for any remaining backdrops 1180 1181 $(document).on('click', '.btn-close, [data-bs-dismiss="modal"]', () => { 1182 1183 // Add a slight delay to ensure modal is in the process of closing 1184 1185 setTimeout(() => { 1186 1187 // Check if no modals are currently being shown 1188 1189 if (!$('.modal.show').length) { 1190 1191 this.clearBackdrops(); 1192 1193 } 1194 1195 }, 300); 1196 1197 }); 1198 1199 }, 1200 1201 1202 1203 ensureSearchVisibility() { 1204 1205 // Force search component to be visible by updating DOM directly if needed 1206 1207 const searchGroup = $('.escribe-search-input-group'); 1208 1209 if (searchGroup.length && !searchGroup.is(':visible')) { 1210 1211 searchGroup.show(); 1212 1213 } 1214 1215 }, 1216 1217 initTooltips() { 1218 1219 this.$nextTick(() => { 1220 1221 // Initialize all tooltips 1222 1223 var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); 1224 1225 tooltipTriggerList.map(function (tooltipTriggerEl) { 1226 1227 // Destroy existing tooltip if it exists 1228 1229 let tooltip = bootstrap.Tooltip.getInstance(tooltipTriggerEl); 1230 1231 if (tooltip) { 1232 1233 tooltip.dispose(); 1234 1235 } 1236 1237 1238 1239 // Create new tooltip 1240 1241 return new bootstrap.Tooltip(tooltipTriggerEl, { 1242 1243 trigger: 'hover focus', // Works for both hover and focus (tap on mobile) 1244 1245 container: tooltipTriggerEl.closest('.modal') ? '.modal-content' : 'body' 1246 1247 }); 1248 1249 }); 1250 1251 }); 1252 1253 }, 1254 1255 setupModalTooltipHandlers() { 1256 1257 // Setup past meetings modal event handler 1258 1259 $(pastModalSel).on('shown.bs.modal', () => { 1260 1261 this.$nextTick(() => { 1262 1263 // Initialize tooltips inside the modal 1264 1265 var modalTooltips = [].slice.call(document.querySelectorAll(pastModalSel + ' [data-bs-toggle="tooltip"]')); 1266 1267 modalTooltips.map(function (tooltipEl) { 1268 1269 // Destroy existing tooltip if it exists 1270 1271 let tooltip = bootstrap.Tooltip.getInstance(tooltipEl); 1272 1273 if (tooltip) { 1274 1275 tooltip.dispose(); 1276 1277 } 1278 1279 1280 1281 // Create new tooltip with modal-specific container 1282 1283 return new bootstrap.Tooltip(tooltipEl, { 1284 1285 trigger: 'hover focus', 1286 1287 container: '.modal-content' 1288 1289 }); 1290 1291 }); 1292 1293 }); 1294 1295 }); 1296 1297 1298 1299 // When modal is hidden, dispose of tooltips to prevent memory leaks 1300 1301 $(pastModalSel).on('hidden.bs.modal', () => { 1302 1303 var modalTooltips = [].slice.call(document.querySelectorAll(pastModalSel + ' [data-bs-toggle="tooltip"]')); 1304 1305 modalTooltips.forEach(function (tooltipEl) { 1306 1307 let tooltip = bootstrap.Tooltip.getInstance(tooltipEl); 1308 1309 if (tooltip) { 1310 1311 tooltip.dispose(); 1312 1313 } 1314 1315 }); 1316 1317 }); 1318 1319 }, 1320 1321 1322 1323 }, 1324 1325 mounted() { 1326 1327 // Read meeting type filter from data attribute 1328 1329 if (mountEl && mountEl.dataset.meetingTypes) { 1330 1331 try { 1332 1333 this.meetingTypeFilter = JSON.parse(mountEl.dataset.meetingTypes); 1334 1335 if (this.meetingTypeFilter.length > 0) { 1336 1337 console.log('eScribe: Filtering by meeting types:', this.meetingTypeFilter); 1338 1339 } 1340 1341 } catch (e) { 1342 1343 console.error('eScribe: Failed to parse meeting type filter', e); 1344 1345 this.meetingTypeFilter = []; 1346 1347 } 1348 1349 } 1350 1351 1352 1353 this.searchInitialized = true; 1354 1355 1356 1357 // Get the meeting data 1358 1359 this.GetData(); 1360 1361 1362 1363 this.clearBackdrops(); 1364 1365 1366 1367 // Setup proper modal event handling 1368 1369 this.setupModalEvents(); 1370 1371 1372 1373 this.initTooltips(); 1374 1375 1376 1377 this.setupModalTooltipHandlers(); 1378 1379 1380 1381 setTimeout(() => { 1382 1383 this.ensureSearchVisibility(); 1384 1385 this.$forceUpdate(); 1386 1387 }, 500); 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 // Handle modal z-index issues 1398 1399 $(pastModalSel).on('show.bs.modal', function() { 1400 1401 setTimeout(function() { 1402 1403 $('.modal-backdrop').css('z-index', '1040'); 1404 1405 $(pastModalSel).css('z-index', '1050'); 1406 1407 $(pastModalSel + ' .modal-dialog').css('z-index', '1051'); 1408 1409 }, 0); 1410 1411 }); 1412 1413 1414 1415 // Additional fix for persistent modal issues 1416 1417 $(document).on('shown.bs.modal', pastModalSel, function() { 1418 1419 $(pastModalSel).appendTo('body'); 1420 1421 $('.modal-backdrop').css('z-index', '1040'); 1422 1423 $(pastModalSel).css('z-index', '1050'); 1424 1425 }); 1426 1427 1428 1429 $(searchModalSel).on('show.bs.modal', function() { 1430 1431 setTimeout(function() { 1432 1433 $('.modal-backdrop').css('z-index', '1040'); 1434 1435 $(searchModalSel).css('z-index', '1050'); 1436 1437 $(searchModalSel + ' .modal-dialog').css('z-index', '1051'); 1438 1439 }, 0); 1440 1441 }); 1442 1443 1444 1445 // The documents modal can be opened on top of the past meetings 1446 // modal, so it has to sit above both that modal and its 1447 // backdrop rather than at the shared 1040/1050 level. 1448 1449 $(docsModalSel).on('show.bs.modal', function() { 1450 1451 $(docsModalSel).appendTo('body'); 1452 1453 setTimeout(function() { 1454 1455 $('.modal-backdrop').last().css('z-index', '1060'); 1456 1457 $(docsModalSel).css('z-index', '1070'); 1458 1459 $(docsModalSel + ' .modal-dialog').css('z-index', '1071'); 1460 1461 }, 0); 1462 1463 }); 1464 1465 1466 1467 $(docsModalSel).on('hidden.bs.modal', () => { 1468 1469 this.clearBackdrops(); 1470 1471 // Bootstrap drops .modal-open when any modal closes; restore 1472 // it if the past meetings modal is still underneath. 1473 1474 if ($('.modal.show').length) { 1475 1476 $('body').addClass('modal-open'); 1477 1478 } 1479 1480 }); 1481 1482 1483 1484 // Additional fix for persistent search modal issues 1485 1486 $(document).on('shown.bs.modal', searchModalSel, function() { 1487 1488 $(searchModalSel).appendTo('body'); 1489 1490 $('.modal-backdrop').css('z-index', '1040'); 1491 1492 $(searchModalSel).css('z-index', '1050'); 1493 1494 $(searchModalSel + ' .modal-dialog').css('z-index', '1051'); 1495 1496 }); 1497 1498 1499 1500 // Set up watchers for search functionality 1501 1502 this.$watch('searchResults', function(newVal) { 1503 1504 if (newVal.length > 0 && this.showSearchResults) { 1505 1506 // Open the search modal when there are results 1507 1508 var searchModal = new bootstrap.Modal(document.getElementById(searchModalId)); 1509 1510 searchModal.show(); 1511 1512 } 1513 1514 }); 1515 1516 1517 1518 this.originalClearSearch = this.clearSearch; 1519 1520 this.clearSearch = function() { 1521 1522 this.originalClearSearch(); 1523 1524 this.clearBackdrops(); 1525 1526 }; 1527 1528 }, 1529 1530 computed: { 1531 1532 visiblePageNumbers() { 1533 1534 let pages = []; 1535 1536 const totalPages = this.totalSearchPages; 1537 1538 const currentPage = this.currentSearchPage; 1539 1540 1541 1542 // For small number of pages, show all 1543 1544 if (totalPages <= 7) { 1545 1546 for (let i = 1; i <= totalPages; i++) { 1547 1548 pages.push(i); 1549 1550 } 1551 1552 } else { 1553 1554 // Always include first page 1555 1556 pages.push(1); 1557 1558 1559 1560 // For current pages near the beginning 1561 1562 if (currentPage <= 4) { 1563 1564 for (let i = 2; i <= 5; i++) { 1565 1566 pages.push(i); 1567 1568 } 1569 1570 pages.push('...'); 1571 1572 pages.push(totalPages); 1573 1574 } 1575 1576 // For current pages near the end 1577 1578 else if (currentPage >= totalPages - 3) { 1579 1580 pages.push('...'); 1581 1582 for (let i = totalPages - 4; i <= totalPages; i++) { 1583 1584 pages.push(i); 1585 1586 } 1587 1588 } 1589 1590 // For current pages in the middle 1591 1592 else { 1593 1594 pages.push('...'); 1595 1596 for (let i = currentPage - 1; i <= currentPage + 1; i++) { 1597 1598 pages.push(i); 1599 1600 } 1601 1602 pages.push('...'); 1603 1604 pages.push(totalPages); 1605 1606 } 1607 1608 } 1609 1610 1611 1612 return pages; 1613 1614 }, 1615 1616 filteredFutureMeetings() { 1617 1618 if (!this.meetingTypeFilter || this.meetingTypeFilter.length === 0) { 1619 1620 // No filter, return all 1621 1622 return this.emeetings.future; 1623 1624 } 1625 1626 1627 1628 // Filter by meeting type 1629 1630 return this.emeetings.future.filter(meeting => { 1631 1632 const meetingTypeId = parseInt(meeting.MeetingTypeId); 1633 1634 return this.meetingTypeFilter.includes(meetingTypeId); 1635 1636 }); 1637 1638 }, 1639 1640 filteredPastMeetings() { 1641 1642 // First filter by year/month selection 1643 1644 let filteredByDate = this.emeetings.past; 1645 1646 1647 1648 if (this.selectedMonth != null && this.selectedYear != null) { 1649 1650 const selectedYearNum = parseInt(this.selectedYear); 1651 1652 const selectedMonthNum = parseInt(this.selectedMonth); 1653 1654 1655 1656 filteredByDate = this.emeetings.past.filter(meeting => { 1657 1658 return meeting.year === selectedYearNum && meeting.month === selectedMonthNum; 1659 1660 }); 1661 1662 } 1663 1664 1665 1666 // Then apply meeting type filter if set 1667 1668 if (!this.meetingTypeFilter || this.meetingTypeFilter.length === 0) { 1669 1670 return filteredByDate; 1671 1672 } 1673 1674 1675 1676 return filteredByDate.filter(meeting => { 1677 1678 const meetingTypeId = parseInt(meeting.MeetingTypeId); 1679 1680 return this.meetingTypeFilter.includes(meetingTypeId); 1681 1682 }); 1683 1684 }, 1685 1686 filteredYears() { 1687 1688 // Get meetings filtered by meeting type only (not by date) 1689 1690 let meetings = this.emeetings.past; 1691 1692 1693 1694 if (this.meetingTypeFilter && this.meetingTypeFilter.length > 0) { 1695 1696 meetings = this.emeetings.past.filter(meeting => { 1697 1698 const meetingTypeId = parseInt(meeting.MeetingTypeId); 1699 1700 return this.meetingTypeFilter.includes(meetingTypeId); 1701 1702 }); 1703 1704 } 1705 1706 1707 1708 // Extract unique years 1709 1710 const yearsSet = new Set(); 1711 1712 meetings.forEa
1712ch(meeting => { 1713 1714 if (meeting.year) { 1715 1716 yearsSet.add(meeting.year); 1717 1718 } 1719 1720 }); 1721 1722 1723 1724 // Convert to array and sort descending 1725 1726 return Array.from(yearsSet).sort((a, b) => b - a); 1727 1728 } 1729 1730 }, 1731 1732 watch: { 1733 1734 meetingTypeFilter: { 1735 1736 handler() { 1737 1738 // When meeting type filter changes, update to most recent filtered date 1739 1740 this.setMostRecentPastDate(); 1741 1742 }, 1743 1744 deep: true 1745 1746 } 1747 1748 } 1749 1750 }); 1751 1752 1753 1754 // Mount the Vue app to this specific element 1755 1756 try { 1757 1758 app.mount(mountEl); 1759 1760 } catch (error) { 1761 1762 console.error('Error mounting eScribe Vue app:', error); 1763 1764 } 1765 1766 }); 1767 1768}); 1769
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.