|
|
| (9 intermediate revisions by the same user not shown) |
| Line 1: |
Line 1: |
| /** | | /** |
| * EveryCRT Advanced Search | | * EveryCRT CRT Search — page-specific configuration |
| * Server-side faceted search using Semantic MediaWiki API | | * Loads the shared search engine and passes CRT-specific filters, printouts, and card layout. |
| */ | | */ |
| (function() { | | (function() { |
| 'use strict'; | | 'use strict'; |
|
| |
|
| var API_ENDPOINT = mw.config.get('wgScriptPath') + '/api.php'; | | mw.loader.getScript( |
| var RESULTS_PER_PAGE = 48;
| | mw.config.get('wgScript') + '?title=MediaWiki:EveryCRT-SearchEngine.js&action=raw&ctype=text/javascript' |
| var currentOffset = 0;
| | ).then(function() { |
| var currentFilters = {}; | |
| var filterOptions = {};
| |
| var totalResults = 0;
| |
| var totalResultsKnown = true; // Track if we know the exact count
| |
| var isLoading = false;
| |
| var searchDebounceTimer = null;
| |
| var DEBOUNCE_DELAY = 400;
| |
|
| |
|
| // Filter configuration - defines all available filters
| | window.EveryCRTSearch({ |
| var FILTER_CONFIG = {
| |
| manufacturing: { | |
| label: 'Manufacturing',
| |
| collapsed: false,
| |
| filters: {
| |
| brand: {
| |
| label: 'Brand',
| |
| type: 'select',
| |
| property: 'Has brand',
| |
| loadFrom: 'category',
| |
| category: 'Manufacturers'
| |
| },
| |
| oem: {
| |
| label: 'OEM Manufacturer',
| |
| type: 'select',
| |
| property: 'Has OEM manufacturer',
| |
| loadFrom: 'category',
| |
| category: 'Manufacturers'
| |
| },
| |
| series: {
| |
| label: 'Series',
| |
| type: 'select',
| |
| property: 'Has series',
| |
| loadFrom: 'category',
| |
| category: 'Series'
| |
| },
| |
| chassis: {
| |
| label: 'Chassis',
| |
| type: 'select',
| |
| property: 'Has chassis',
| |
| loadFrom: 'category',
| |
| category: 'Chassis'
| |
| },
| |
| chassis_family: {
| |
| label: 'Chassis Family',
| |
| type: 'select',
| |
| property: 'Has chassis family',
| |
| loadFrom: 'category',
| |
| category: 'Chassis families'
| |
| }
| |
| }
| |
| },
| |
| display: {
| |
| label: 'Display',
| |
| collapsed: false,
| |
| filters: {
| |
| crt_type: {
| |
| label: 'Type',
| |
| type: 'select',
| |
| property: 'Has CRT type',
| |
| loadFrom: 'static',
| |
| values: ['TV', 'Monitor', 'PVM', 'BVM', 'Computer monitor', 'Presentation monitor', 'Other']
| |
| },
| |
| form_factor: {
| |
| label: 'Form Factor',
| |
| type: 'select',
| |
| property: 'Has form factor',
| |
| loadFrom: 'static',
| |
| values: ['Standard', 'Console', 'Portable', 'Wall-mount', 'Other']
| |
| },
| |
| screen_size_min: {
| |
| label: 'Screen Size (min)',
| |
| type: 'number',
| |
| property: 'Has screen size inches',
| |
| comparison: '>=',
| |
| placeholder: 'Min inches'
| |
| },
| |
| screen_size_max: {
| |
| label: 'Screen Size (max)',
| |
| type: 'number',
| |
| property: 'Has screen size inches',
| |
| comparison: '<=',
| |
| placeholder: 'Max inches'
| |
| },
| |
| aspect_ratio: {
| |
| label: 'Aspect Ratio',
| |
| type: 'select',
| |
| property: 'Has aspect ratio',
| |
| loadFrom: 'static',
| |
| values: ['4:3', '16:9']
| |
| },
| |
| tvl_min: {
| |
| label: 'TVL (min)',
| |
| type: 'number',
| |
| property: 'Has TVL',
| |
| comparison: '>=',
| |
| placeholder: 'Min lines'
| |
| }
| |
| }
| |
| },
| |
| videoSpecs: {
| |
| label: 'Video Specifications',
| |
| collapsed: true,
| |
| filters: {
| |
| signal_format: {
| |
| label: 'Signal Format',
| |
| type: 'multiselect',
| |
| subobjectQuery: true,
| |
| subobjectProperty: 'Has video signal format',
| |
| loadFrom: 'category',
| |
| category: 'Video signal formats'
| |
| },
| |
| connector: {
| |
| label: 'Connector',
| |
| type: 'multiselect',
| |
| subobjectQuery: true,
| |
| subobjectProperty: 'Has connector type',
| |
| loadFrom: 'category',
| |
| category: 'Connector types'
| |
| }
| |
| }
| |
| },
| |
| mods: {
| |
| label: 'Modifications',
| |
| collapsed: true,
| |
| filters: {
| |
| has_rgb_mod: {
| |
| label: 'Has RGB Mod',
| |
| type: 'checkbox',
| |
| subobjectQuery: true,
| |
| subobjectProperty: 'Has mod type',
| |
| subobjectValue: 'RGB',
| |
| subobjectExtraCondition: '[[Has mod feasible::Yes]]'
| |
| },
| |
| has_svideo_mod: {
| |
| label: 'Has S-Video Mod',
| |
| type: 'checkbox',
| |
| subobjectQuery: true,
| |
| subobjectProperty: 'Has mod type',
| |
| subobjectValue: 'S-video',
| |
| subobjectExtraCondition: '[[Has mod feasible::Yes]]'
| |
| }
| |
| }
| |
| },
| |
| tvSystem: {
| |
| label: 'TV System',
| |
| collapsed: true,
| |
| filters: {
| |
| av_system: {
| |
| label: 'AV System',
| |
| type: 'multiselect',
| |
| property: 'AV system',
| |
| loadFrom: 'category',
| |
| category: 'AV systems'
| |
| },
| |
| frequency: {
| |
| label: 'AC Frequency',
| |
| type: 'multiselect',
| |
| property: 'Frequency',
| |
| loadFrom: 'static',
| |
| values: ['50Hz', '60Hz']
| |
| }
| |
| }
| |
| },
| |
| builtIn: {
| |
| label: 'Built-in Devices',
| |
| collapsed: true,
| |
| filters: {
| |
| built_in_vcr: {
| |
| label: 'VCR',
| |
| type: 'checkbox',
| |
| property: 'Has built-in devices',
| |
| containsValue: 'VCR'
| |
| },
| |
| built_in_dvd: {
| |
| label: 'DVD',
| |
| type: 'checkbox',
| |
| property: 'Has built-in devices',
| |
| containsValue: 'DVD'
| |
| }
| |
| }
| |
| }
| |
| };
| |
|
| |
|
| /**
| | containerId: 'advanced-search-app', |
| * Trigger search with debounce for text/number inputs
| | category: 'CRT models', |
| */
| | resultLabel: 'CRTs', |
| function triggerSearch(immediate) {
| | noImageEmoji: '\uD83D\uDCFA', |
| if (searchDebounceTimer) {
| | subobjectParentProperty: 'Has CRT model', |
| clearTimeout(searchDebounceTimer); | | defaultSort: 'brand', |
| }
| |
|
| |
| if (immediate) {
| |
| currentOffset = 0; | |
| performSearch(); | |
| updateURL(); | |
| } else {
| |
| searchDebounceTimer = setTimeout(function() {
| |
| currentOffset = 0;
| |
| performSearch();
| |
| updateURL();
| |
| }, DEBOUNCE_DELAY); | |
| }
| |
| }
| |
|
| |
|
| /**
| | // ── Sort options ─────────────────────────────────────── |
| * Initialize the advanced search interface
| | sortOptions: [ |
| */
| | { value: 'brand', label: 'Brand', property: 'Has brand', printoutKey: 'brand' }, |
| function init() {
| | { value: 'model', label: 'Model', property: 'Has model code', printoutKey: 'model' }, |
| var container = document.getElementById('advanced-search-app'); | | { value: 'screen_size', label: 'Screen Size', property: 'Has screen size inches', printoutKey: 'size' }, |
| if (!container) {
| | { value: 'type', label: 'Type', property: 'Has CRT type', printoutKey: 'type' } |
| console.error('Advanced search container not found');
| | ], |
| return; | |
| }
| |
|
| |
|
| container.innerHTML = buildHTML();
| | // ── Extra printouts (image, brand, model are always included) ── |
| attachEventListeners();
| | printouts: [ |
|
| | { property: 'Has CRT type', key: 'type' }, |
| // Load filter options, then parse URL params, then perform initial search
| | { property: 'Has screen size inches', key: 'size' }, |
| // URL params must be parsed AFTER filter options are loaded so that
| | { property: 'Has aspect ratio', key: 'aspect' }, |
| // select dropdowns have their options populated before we try to set values
| | { property: 'Has TVL', key: 'tvl' } |
| loadFilterOptions();
| | ], |
| }
| |
|
| |
|
| /**
| | // ── Card meta fields ─────────────────────────────────── |
| * Build the main HTML structure
| | cardMeta: [ |
| */
| | { key: 'type', cssClass: 'crt-meta-type' }, |
| function buildHTML() {
| | { key: 'size', cssClass: 'crt-meta-size', suffix: '"' }, |
| var html = '<div class="crt-search-wrapper">';
| | { key: 'aspect', cssClass: 'crt-meta-aspect' }, |
|
| | { key: 'tvl', cssClass: 'crt-meta-tvl', suffix: ' TVL' } |
| // Mobile filter toggle
| | ], |
| html += '<button type="button" class="crt-mobile-filter-toggle" id="mobile-filter-toggle">';
| |
| html += '<span class="crt-filter-icon">☰</span> Filters';
| |
| html += '</button>';
| |
|
| |
| // Filters sidebar
| |
| html += '<div class="crt-search-filters" id="filters-sidebar">';
| |
| html += '<div class="crt-filters-header">';
| |
| html += '<h2>Filters</h2>';
| |
| html += '<button type="button" class="crt-reset-btn" id="reset-filters">Reset All</button>';
| |
| html += '</div>';
| |
|
| |
| // Scrollable body for filter sections
| |
| html += '<div class="crt-filters-body">';
| |
|
| |
| // Build filter sections
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| var section = FILTER_CONFIG[sectionKey];
| |
| html += buildFilterSection(sectionKey, section);
| |
| }
| |
|
| |
| html += '</div>'; // end crt-filters-body
| |
|
| |
| // Apply button (shown only on mobile)
| |
| html += '<div class="crt-apply-filters">';
| |
| html += '<button type="button" class="crt-apply-btn" id="apply-filters">Apply Filters</button>';
| |
| html += '</div>';
| |
| html += '</div>';
| |
|
| |
| // Results area
| |
| html += '<div class="crt-search-results">';
| |
| html += '<div class="crt-results-header">';
| |
| html += '<div class="crt-results-count" id="results-count">Loading...</div>';
| |
| html += '<div class="crt-results-controls">';
| |
| html += '<div class="crt-results-sort">';
| |
| html += '<label for="sort-by">Sort by:</label>';
| |
| html += '<select id="sort-by">';
| |
| html += '<option value="brand">Brand</option>';
| |
| html += '<option value="model">Model</option>';
| |
| html += '<option value="screen_size">Screen Size</option>';
| |
| html += '<option value="type">Type</option>';
| |
| html += '</select>';
| |
| html += '</div>';
| |
| html += '<div class="crt-view-toggle">';
| |
| html += '<button class="crt-view-btn active" data-view="grid" title="Grid view">▦</button>';
| |
| html += '<button class="crt-view-btn" data-view="list" title="List view">≡</button>';
| |
| html += '</div>';
| |
| html += '</div>';
| |
| html += '</div>';
| |
| html += '<div class="crt-active-filters" id="active-filters"></div>';
| |
| html += '<div class="crt-results-grid" id="results-grid"></div>';
| |
| html += '<div class="crt-pagination" id="pagination"></div>';
| |
| html += '</div>';
| |
|
| |
| html += '</div>';
| |
| return html;
| |
| }
| |
|
| |
|
| /**
| | // ── Filter configuration ─────────────────────────────── |
| * Build a collapsible filter section
| | filterConfig: { |
| */
| | manufacturing: { |
| function buildFilterSection(sectionKey, section) {
| | label: 'Manufacturing', |
| var collapsed = section.collapsed ? ' collapsed' : '';
| | collapsed: false, |
| var html = '<div class="crt-filter-section' + collapsed + '" data-section="' + sectionKey + '">';
| | filters: { |
| html += '<div class="crt-section-header">';
| | brand: { |
| html += '<span class="crt-section-title">' + section.label + '</span>';
| | label: 'Brand', |
| html += '<span class="crt-section-toggle">▼</span>';
| | type: 'select', |
| html += '</div>';
| | property: 'Has brand', |
| html += '<div class="crt-section-content">';
| | loadFrom: 'category', |
|
| | category: 'Manufacturers' |
| for (var filterKey in section.filters) {
| | }, |
| var filter = section.filters[filterKey];
| | oem: { |
| html += buildFilterInput(filterKey, filter);
| | label: 'OEM Manufacturer', |
| }
| | type: 'select', |
|
| | property: 'Has OEM manufacturer', |
| html += '</div>';
| | loadFrom: 'category', |
| html += '</div>';
| | category: 'Manufacturers' |
| return html;
| | }, |
| }
| | series: { |
| | | label: 'Series', |
| /**
| | type: 'select', |
| * Build individual filter input
| | property: 'Has series', |
| */
| | loadFrom: 'category', |
| function buildFilterInput(filterKey, filter) {
| | category: 'Series' |
| var html = '<div class="crt-filter-item" data-filter="' + filterKey + '">';
| | }, |
|
| | chassis: { |
| switch (filter.type) {
| | label: 'Chassis', |
| case 'select':
| | type: 'select', |
| html += '<label for="filter-' + filterKey + '">' + filter.label + '</label>';
| | property: 'Has chassis', |
| html += '<select id="filter-' + filterKey + '" class="crt-filter-select" data-filter="' + filterKey + '">';
| | loadFrom: 'category', |
| html += '<option value="">All</option>';
| | category: 'Chassis' |
| if (filter.loadFrom === 'static' && filter.values) {
| | }, |
| for (var i = 0; i < filter.values.length; i++) {
| | chassis_family: { |
| html += '<option value="' + filter.values[i] + '">' + filter.values[i] + '</option>'; | | label: 'Chassis Family', |
| | type: 'select', |
| | property: 'Has chassis family', |
| | loadFrom: 'category', |
| | category: 'Chassis families' |
| | } |
| } | | } |
| } | | }, |
| html += '</select>'; | | display: { |
| break;
| | label: 'Display', |
|
| | collapsed: false, |
| case 'multiselect':
| | filters: { |
| html += '<label>' + filter.label + '</label>';
| | crt_type: { |
| html += '<div class="crt-multiselect" id="filter-' + filterKey + '" data-filter="' + filterKey + '">';
| | label: 'Type', |
| html += '<div class="crt-multiselect-selected"></div>';
| | type: 'select', |
| html += '<input type="text" class="crt-multiselect-input" placeholder="Search ' + filter.label.toLowerCase() + '...">';
| | property: 'Has CRT type', |
| html += '<div class="crt-multiselect-dropdown"></div>';
| | loadFrom: 'category', |
| html += '</div>';
| | category: 'CRT types', |
| break;
| | categoryMemberTypes: 'subcat' |
|
| | }, |
| case 'number':
| | form_factor: { |
| html += '<label for="filter-' + filterKey + '">' + filter.label + '</label>';
| | label: 'Form Factor', |
| html += '<input type="number" id="filter-' + filterKey + '" class="crt-filter-number" ';
| | type: 'select', |
| html += 'data-filter="' + filterKey + '" placeholder="' + (filter.placeholder || '') + '">';
| | property: 'Has form factor', |
| break;
| | loadFrom: 'static', |
|
| | values: ['Standard', 'Console', 'Portable', 'Wall-mount', 'Other'] |
| case 'checkbox':
| | }, |
| html += '<label class="crt-checkbox-label">';
| | screen_size_min: { |
| html += '<input type="checkbox" id="filter-' + filterKey + '" class="crt-filter-checkbox" ';
| | label: 'Screen Size (min)', |
| html += 'data-filter="' + filterKey + '">';
| | type: 'number', |
| html += '<span>' + filter.label + '</span>';
| | property: 'Has screen size inches', |
| html += '</label>';
| | comparison: '>=', |
| break;
| | placeholder: 'Min inches' |
|
| | }, |
| case 'range':
| | screen_size_max: { |
| html += '<label>' + filter.label + '</label>';
| | label: 'Screen Size (max)', |
| html += '<div class="crt-range-inputs">';
| | type: 'number', |
| html += '<input type="number" id="filter-' + filterKey + '-min" class="crt-filter-range-min" ';
| | property: 'Has screen size inches', |
| html += 'data-filter="' + filterKey + '-min" placeholder="Min">';
| | comparison: '<=', |
| html += '<span class="crt-range-sep">–</span>';
| | placeholder: 'Max inches' |
| html += '<input type="number" id="filter-' + filterKey + '-max" class="crt-filter-range-max" ';
| | }, |
| html += 'data-filter="' + filterKey + '-max" placeholder="Max">';
| | aspect_ratio: { |
| html += '</div>';
| | label: 'Aspect Ratio', |
| break;
| | type: 'select', |
| }
| | property: 'Has aspect ratio', |
|
| | loadFrom: 'static', |
| html += '</div>';
| | values: ['4:3', '16:9'] |
| return html;
| | }, |
| }
| | tvl_min: { |
| | | label: 'TVL (min)', |
| /**
| | type: 'number', |
| * Attach event listeners
| | property: 'Has TVL', |
| */
| | comparison: '>=', |
| function attachEventListeners() {
| | placeholder: 'Min lines' |
| // Mobile filter toggle
| | } |
| document.getElementById('mobile-filter-toggle').addEventListener('click', function() {
| |
| document.getElementById('filters-sidebar').classList.toggle('mobile-open');
| |
| this.classList.toggle('active');
| |
| });
| |
|
| |
| // Section collapse/expand
| |
| document.querySelectorAll('.crt-section-header').forEach(function(header) {
| |
| header.addEventListener('click', function() {
| |
| var section = this.closest('.crt-filter-section');
| |
| section.classList.toggle('collapsed');
| |
| });
| |
| });
| |
|
| |
| // Apply filters button (mobile fallback)
| |
| document.getElementById('apply-filters').addEventListener('click', function() {
| |
| currentOffset = 0;
| |
| performSearch();
| |
| updateURL();
| |
|
| |
| // Close mobile filters
| |
| document.getElementById('filters-sidebar').classList.remove('mobile-open');
| |
| document.getElementById('mobile-filter-toggle').classList.remove('active');
| |
| });
| |
|
| |
| // Reset filters button
| |
| document.getElementById('reset-filters').addEventListener('click', resetFilters);
| |
|
| |
| // Sort change - immediate
| |
| document.getElementById('sort-by').addEventListener('change', function() {
| |
| triggerSearch(true);
| |
| });
| |
|
| |
| // View toggle
| |
| document.querySelectorAll('.crt-view-btn').forEach(function(btn) {
| |
| btn.addEventListener('click', function() {
| |
| document.querySelectorAll('.crt-view-btn').forEach(function(b) {
| |
| b.classList.remove('active');
| |
| });
| |
| this.classList.add('active');
| |
| var view = this.dataset.view;
| |
| var grid = document.getElementById('results-grid');
| |
| grid.classList.remove('grid-view', 'list-view');
| |
| grid.classList.add(view + '-view');
| |
| });
| |
| });
| |
|
| |
| // Select change - immediate search
| |
| document.querySelectorAll('.crt-filter-select').forEach(function(select) {
| |
| select.addEventListener('change', function() {
| |
| triggerSearch(true);
| |
| });
| |
| });
| |
|
| |
| // Number input change - debounced search
| |
| document.querySelectorAll('.crt-filter-number').forEach(function(input) {
| |
| input.addEventListener('input', function() {
| |
| triggerSearch(false);
| |
| });
| |
| input.addEventListener('keypress', function(e) {
| |
| if (e.key === 'Enter') {
| |
| triggerSearch(true);
| |
| }
| |
| });
| |
| });
| |
|
| |
| // Checkbox change - immediate search
| |
| document.querySelectorAll('.crt-filter-checkbox').forEach(function(checkbox) {
| |
| checkbox.addEventListener('change', function() {
| |
| triggerSearch(true);
| |
| });
| |
| });
| |
|
| |
| // Multiselect handling
| |
| initMultiselects();
| |
| }
| |
| | |
| /**
| |
| * Initialize multiselect dropdowns
| |
| */
| |
| function initMultiselects() {
| |
| document.querySelectorAll('.crt-multiselect').forEach(function(ms) {
| |
| var input = ms.querySelector('.crt-multiselect-input');
| |
| var dropdown = ms.querySelector('.crt-multiselect-dropdown');
| |
| var filterKey = ms.dataset.filter;
| |
|
| |
| // Show dropdown on input focus
| |
| input.addEventListener('focus', function() {
| |
| dropdown.classList.add('visible');
| |
| updateMultiselectDropdown(ms, filterKey);
| |
| });
| |
|
| |
| // Filter dropdown on input
| |
| input.addEventListener('input', function() {
| |
| updateMultiselectDropdown(ms, filterKey, this.value);
| |
| });
| |
|
| |
| // Hide dropdown on click outside
| |
| document.addEventListener('click', function(e) {
| |
| if (!ms.contains(e.target)) {
| |
| dropdown.classList.remove('visible');
| |
| }
| |
| });
| |
| });
| |
| }
| |
| | |
| /**
| |
| * Update multiselect dropdown with filtered options
| |
| */
| |
| function updateMultiselectDropdown(ms, filterKey, searchText) {
| |
| var dropdown = ms.querySelector('.crt-multiselect-dropdown');
| |
| var options = filterOptions[filterKey] || [];
| |
| var selected = getMultiselectValues(ms);
| |
|
| |
| searchText = (searchText || '').toLowerCase();
| |
|
| |
| var html = '';
| |
| var count = 0;
| |
| options.forEach(function(opt) {
| |
| var optValue = typeof opt === 'object' ? opt.value : opt;
| |
| var optLabel = typeof opt === 'object' ? opt.label : opt;
| |
|
| |
| if (searchText && optLabel.toLowerCase().indexOf(searchText) === -1) {
| |
| return;
| |
| }
| |
|
| |
| var isSelected = selected.indexOf(optValue) !== -1;
| |
| html += '<div class="crt-multiselect-option' + (isSelected ? ' selected' : '') + '" data-value="' + escapeHtml(optValue) + '">';
| |
| html += '<span class="crt-option-check">' + (isSelected ? '✓' : '') + '</span>';
| |
| html += '<span class="crt-option-label">' + escapeHtml(optLabel) + '</span>';
| |
| html += '</div>';
| |
| count++;
| |
| });
| |
|
| |
| if (count === 0) {
| |
| html = '<div class="crt-multiselect-empty">No options found</div>';
| |
| }
| |
|
| |
| dropdown.innerHTML = html;
| |
|
| |
| // Attach click handlers
| |
| dropdown.querySelectorAll('.crt-multiselect-option').forEach(function(opt) {
| |
| opt.addEventListener('click', function(e) {
| |
| e.stopPropagation();
| |
| toggleMultiselectOption(ms, this.dataset.value);
| |
| });
| |
| });
| |
| }
| |
| | |
| /**
| |
| * Toggle a multiselect option
| |
| */
| |
| function toggleMultiselectOption(ms, value) {
| |
| var selectedContainer = ms.querySelector('.crt-multiselect-selected');
| |
| var existing = selectedContainer.querySelector('[data-value="' + CSS.escape(value) + '"]');
| |
|
| |
| if (existing) {
| |
| existing.remove();
| |
| } else {
| |
| var tag = document.createElement('span');
| |
| tag.className = 'crt-multiselect-tag';
| |
| tag.dataset.value = value;
| |
| tag.innerHTML = escapeHtml(value) + ' <span class="crt-tag-remove">×</span>';
| |
| tag.querySelector('.crt-tag-remove').addEventListener('click', function(e) {
| |
| e.stopPropagation();
| |
| tag.remove();
| |
| updateMultiselectDropdown(ms, ms.dataset.filter, ms.querySelector('.crt-multiselect-input').value);
| |
| triggerSearch(true);
| |
| });
| |
| selectedContainer.appendChild(tag);
| |
| }
| |
|
| |
| updateMultiselectDropdown(ms, ms.dataset.filter, ms.querySelector('.crt-multiselect-input').value);
| |
| triggerSearch(true);
| |
| }
| |
| | |
| /**
| |
| * Get selected values from a multiselect
| |
| */
| |
| function getMultiselectValues(ms) {
| |
| var values = [];
| |
| ms.querySelectorAll('.crt-multiselect-tag').forEach(function(tag) {
| |
| values.push(tag.dataset.value);
| |
| });
| |
| return values;
| |
| }
| |
| | |
| /**
| |
| * Load filter options from SMW
| |
| */
| |
| function loadFilterOptions() {
| |
| var promises = [];
| |
|
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| var section = FILTER_CONFIG[sectionKey];
| |
| for (var filterKey in section.filters) {
| |
| var filter = section.filters[filterKey];
| |
|
| |
| if (filter.loadFrom === 'category') {
| |
| promises.push(loadCategoryOptions(filterKey, filter.category));
| |
| } else if (filter.loadFrom === 'static' && filter.values) {
| |
| filterOptions[filterKey] = filter.values.map(function(v) {
| |
| return { value: v, label: v }; | |
| });
| |
| }
| |
| }
| |
| }
| |
|
| |
| Promise.all(promises).then(function() {
| |
| // First populate the select dropdowns with their options
| |
| populateSelectFilters();
| |
|
| |
| // Then parse URL params - this must happen AFTER options are loaded
| |
| // so that select values can be properly set
| |
| parseURLParams();
| |
|
| |
| // Finally perform the initial search with any URL-specified filters
| |
| performSearch();
| |
| });
| |
| }
| |
| | |
| /**
| |
| * Load options from a category
| |
| */
| |
| function loadCategoryOptions(filterKey, category) {
| |
| return new Promise(function(resolve) {
| |
| var api = new mw.Api();
| |
| api.get({
| |
| action: 'query',
| |
| list: 'categorymembers',
| |
| cmtitle: 'Category:' + category,
| |
| cmlimit: 500,
| |
| format: 'json'
| |
| }).done(function(data) {
| |
| filterOptions[filterKey] = [];
| |
| if (data.query && data.query.categorymembers) {
| |
| data.query.categorymembers.forEach(function(member) {
| |
| filterOptions[filterKey].push({
| |
| value: member.title,
| |
| label: member.title | |
| });
| |
| });
| |
| filterOptions[filterKey].sort(function(a, b) {
| |
| return a.label.localeCompare(b.label);
| |
| });
| |
| }
| |
| resolve();
| |
| }).fail(function() {
| |
| filterOptions[filterKey] = [];
| |
| resolve();
| |
| });
| |
| });
| |
| }
| |
| | |
| /**
| |
| * Populate select filters with loaded options
| |
| */
| |
| function populateSelectFilters() {
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| var section = FILTER_CONFIG[sectionKey];
| |
| for (var filterKey in section.filters) {
| |
| var filter = section.filters[filterKey];
| |
|
| |
| if (filter.type === 'select' && filter.loadFrom === 'category') {
| |
| var select = document.getElementById('filter-' + filterKey);
| |
| if (select && filterOptions[filterKey]) {
| |
| var currentValue = select.value;
| |
| select.innerHTML = '<option value="">All</option>';
| |
| filterOptions[filterKey].forEach(function(opt) {
| |
| var option = document.createElement('option');
| |
| option.value = opt.value;
| |
| option.textContent = opt.label;
| |
| select.appendChild(option);
| |
| }); | |
| select.value = currentValue;
| |
| } | | } |
| } | | }, |
| }
| | videoSpecs: { |
| }
| | label: 'Video Specifications', |
| }
| | collapsed: true, |
| | | filters: { |
| /**
| | signal_format: { |
| * Collect current filter values
| | label: 'Signal Format', |
| */
| | type: 'multiselect', |
| function collectFilters() {
| | subobjectQuery: true, |
| var filters = {};
| | subobjectProperty: 'Has video signal format', |
|
| | loadFrom: 'category', |
| // Collect select and number filters
| | category: 'Video signal formats' |
| document.querySelectorAll('.crt-filter-select, .crt-filter-number').forEach(function(input) {
| | }, |
| var value = input.value.trim();
| | connector: { |
| if (value) {
| | label: 'Connector', |
| filters[input.dataset.filter] = value; | | type: 'multiselect', |
| }
| | subobjectQuery: true, |
| });
| | subobjectProperty: 'Has connector type', |
|
| | loadFrom: 'category', |
| // Collect checkbox filters
| | category: 'Connector types' |
| document.querySelectorAll('.crt-filter-checkbox').forEach(function(input) {
| | } |
| if (input.checked) {
| |
| filters[input.dataset.filter] = true;
| |
| }
| |
| });
| |
|
| |
| // Collect multiselect filters
| |
| document.querySelectorAll('.crt-multiselect').forEach(function(ms) {
| |
| var values = getMultiselectValues(ms);
| |
| if (values.length > 0) {
| |
| filters[ms.dataset.filter] = values;
| |
| }
| |
| });
| |
|
| |
| return filters;
| |
| }
| |
| | |
| /**
| |
| * Reset all filters
| |
| */
| |
| function resetFilters() {
| |
| document.querySelectorAll('.crt-filter-select, .crt-filter-number').forEach(function(input) {
| |
| input.value = '';
| |
| });
| |
|
| |
| document.querySelectorAll('.crt-filter-checkbox').forEach(function(input) {
| |
| input.checked = false;
| |
| });
| |
|
| |
| document.querySelectorAll('.crt-multiselect-selected').forEach(function(container) {
| |
| container.innerHTML = '';
| |
| });
| |
|
| |
| currentOffset = 0;
| |
|
| |
| // Clear URL params but preserve 'title' if present
| |
| var params = new URLSearchParams(window.location.search);
| |
| var titleParam = params.get('title');
| |
| var newUrl = window.location.pathname;
| |
| if (titleParam) {
| |
| newUrl += '?title=' + encodeURIComponent(titleParam);
| |
| }
| |
| history.replaceState(null, '', newUrl);
| |
|
| |
| performSearch();
| |
| }
| |
| | |
| /**
| |
| * Update active filters display
| |
| */
| |
| function updateActiveFilters() {
| |
| var filters = collectFilters();
| |
| var container = document.getElementById('active-filters');
| |
| var html = '';
| |
|
| |
| var hasFilters = false;
| |
| for (var key in filters) {
| |
| hasFilters = true;
| |
| var value = filters[key];
| |
| var label = getFilterLabel(key);
| |
|
| |
| if (Array.isArray(value)) {
| |
| value.forEach(function(v) {
| |
| html += '<span class="crt-active-filter" data-filter="' + key + '" data-value="' + escapeHtml(v) + '">'; | |
| html += '<span class="crt-af-label">' + label + ':</span> ';
| |
| html += '<span class="crt-af-value">' + escapeHtml(v) + '</span>';
| |
| html += ' <span class="crt-af-remove">×</span>'; | |
| html += '</span>'; | |
| });
| |
| } else if (value === true) {
| |
| html += '<span class="crt-active-filter" data-filter="' + key + '">';
| |
| html += '<span class="crt-af-value">' + label + '</span>';
| |
| html += ' <span class="crt-af-remove">×</span>';
| |
| html += '</span>';
| |
| } else {
| |
| html += '<span class="crt-active-filter" data-filter="' + key + '">';
| |
| html += '<span class="crt-af-label">' + label + ':</span> ';
| |
| html += '<span class="crt-af-value">' + escapeHtml(value) + '</span>';
| |
| html += ' <span class="crt-af-remove">×</span>';
| |
| html += '</span>';
| |
| }
| |
| }
| |
|
| |
| if (hasFilters) {
| |
| html += '<button class="crt-clear-all-filters">Clear All</button>';
| |
| }
| |
|
| |
| container.innerHTML = html;
| |
|
| |
| // Attach remove handlers
| |
| container.querySelectorAll('.crt-active-filter').forEach(function(af) {
| |
| af.querySelector('.crt-af-remove').addEventListener('click', function() {
| |
| removeActiveFilter(af.dataset.filter, af.dataset.value);
| |
| });
| |
| });
| |
|
| |
| var clearAll = container.querySelector('.crt-clear-all-filters');
| |
| if (clearAll) {
| |
| clearAll.addEventListener('click', resetFilters);
| |
| }
| |
| }
| |
| | |
| /**
| |
| * Remove an active filter
| |
| */
| |
| function removeActiveFilter(filterKey, value) {
| |
| // Find the filter config
| |
| var filterConfig = null;
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| if (FILTER_CONFIG[sectionKey].filters[filterKey]) {
| |
| filterConfig = FILTER_CONFIG[sectionKey].filters[filterKey];
| |
| break;
| |
| }
| |
| }
| |
|
| |
| if (!filterConfig) return;
| |
|
| |
| switch (filterConfig.type) {
| |
| case 'select':
| |
| case 'number':
| |
| document.getElementById('filter-' + filterKey).value = '';
| |
| break;
| |
| case 'checkbox':
| |
| document.getElementById('filter-' + filterKey).checked = false;
| |
| break;
| |
| case 'multiselect':
| |
| var ms = document.getElementById('filter-' + filterKey);
| |
| var tag = ms.querySelector('.crt-multiselect-tag[data-value="' + CSS.escape(value) + '"]');
| |
| if (tag) tag.remove();
| |
| break;
| |
| }
| |
|
| |
| triggerSearch(true);
| |
| }
| |
| | |
| /**
| |
| * Get human-readable label for a filter key
| |
| */
| |
| function getFilterLabel(filterKey) {
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| var section = FILTER_CONFIG[sectionKey];
| |
| if (section.filters[filterKey]) {
| |
| return section.filters[filterKey].label;
| |
| }
| |
| }
| |
| return filterKey;
| |
| }
| |
| | |
| /**
| |
| * Build SMW query from filters
| |
| */
| |
| function buildSMWQuery() {
| |
| var filters = collectFilters();
| |
| var conditions = ['[[Category:CRT models]]'];
| |
| var subobjectConditions = [];
| |
|
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| var section = FILTER_CONFIG[sectionKey];
| |
| for (var filterKey in section.filters) {
| |
| var filter = section.filters[filterKey];
| |
| var value = filters[filterKey];
| |
|
| |
| if (value === undefined || value === '') continue;
| |
|
| |
| if (filter.subobjectQuery) {
| |
| // Handle subobject queries differently
| |
| if (filter.type === 'checkbox' && value === true) {
| |
| // Use subquery for mod checks
| |
| subobjectConditions.push({
| |
| property: filter.subobjectProperty, | |
| value: filter.subobjectValue, | |
| extraCondition: filter.subobjectExtraCondition || '' | |
| }); | |
| } | | } |
| else if (filter.type === 'multiselect' && Array.isArray(value) && value.length > 0) { | | }, |
| value.forEach(function(v) { | | mods: { |
| subobjectConditions.push({ | | label: 'Modifications', |
| property: filter.subobjectProperty,
| | collapsed: true, |
| value: v,
| | filters: { |
| extraCondition: filter.subobjectExtraCondition || ''
| | has_rgb_mod: { |
| }); | | label: 'Has RGB Mod', |
| }); | | type: 'checkbox', |
| | subobjectQuery: true, |
| | subobjectProperty: 'Has mod type', |
| | subobjectValue: 'RGB', |
| | subobjectExtraCondition: '[[Has mod feasible::Yes]]' |
| | }, |
| | has_svideo_mod: { |
| | label: 'Has S-Video Mod', |
| | type: 'checkbox', |
| | subobjectQuery: true, |
| | subobjectProperty: 'Has mod type', |
| | subobjectValue: 'S-video', |
| | subobjectExtraCondition: '[[Has mod feasible::Yes]]' |
| | } |
| } | | } |
| } | | }, |
| else if (filter.type === 'select' && filter.property) { | | tvSystem: { |
| conditions.push('[[' + filter.property + '::' + value + ']]'); | | label: 'TV System', |
| }
| | collapsed: true, |
| else if (filter.type === 'number' && filter.property) {
| | filters: { |
| var comp = filter.comparison || '=';
| | av_system: { |
| if (comp === '>=') {
| | label: 'AV System', |
| conditions.push('[[' + filter.property + '::≥' + value + ']]'); | | type: 'multiselect', |
| } else if (comp === '<=') {
| | property: 'AV system', |
| conditions.push('[[' + filter.property + '::≤' + value + ']]');
| | loadFrom: 'category', |
| } else {
| | category: 'AV systems' |
| conditions.push('[[' + filter.property + '::' + value + ']]');
| | }, |
| | frequency: { |
| | label: 'AC Frequency', |
| | type: 'multiselect', |
| | property: 'Frequency', |
| | loadFrom: 'static', |
| | values: ['50Hz', '60Hz'] |
| | } |
| } | | } |
| } | | }, |
| else if (filter.type === 'checkbox' && filter.property && value === true) { | | builtIn: { |
| if (filter.containsValue) { | | label: 'Built-in Devices', |
| conditions.push('[[' + filter.property + '::~*' + filter.containsValue + '*]]'); | | collapsed: true, |
| | filters: { |
| | built_in_vcr: { |
| | label: 'VCR', |
| | type: 'checkbox', |
| | property: 'Has built-in devices', |
| | containsValue: 'VCR' |
| | }, |
| | built_in_dvd: { |
| | label: 'DVD', |
| | type: 'checkbox', |
| | property: 'Has built-in devices', |
| | containsValue: 'DVD' |
| | } |
| } | | } |
| } | | } |
| else if (filter.type === 'multiselect' && filter.property && Array.isArray(value) && value.length > 0) {
| |
| // OR condition for multi-select
| |
| var orParts = value.map(function(v) {
| |
| return filter.property + '::' + v;
| |
| });
| |
| conditions.push('[[' + orParts.join('||') + ']]');
| |
| }
| |
| }
| |
| }
| |
|
| |
| return {
| |
| mainQuery: conditions.join(''),
| |
| subobjectConditions: subobjectConditions
| |
| };
| |
| }
| |
|
| |
| /**
| |
| * Perform the search using SMW API
| |
| */
| |
| function performSearch() {
| |
| if (isLoading) return;
| |
|
| |
| isLoading = true;
| |
| showLoading();
| |
| updateActiveFilters();
| |
|
| |
| var queryData = buildSMWQuery();
| |
| var query = queryData.mainQuery;
| |
| var sortBy = document.getElementById('sort-by').value;
| |
|
| |
| var sortProperty = 'Has brand';
| |
| switch (sortBy) {
| |
| case 'model': sortProperty = 'Has model code'; break;
| |
| case 'screen_size': sortProperty = 'Has screen size inches'; break;
| |
| case 'type': sortProperty = 'Has CRT type'; break;
| |
| }
| |
|
| |
| // If we have subobject conditions, we need to use a different query approach
| |
| if (queryData.subobjectConditions.length > 0) {
| |
| performSubobjectSearch(queryData, sortProperty);
| |
| return;
| |
| }
| |
|
| |
| var api = new mw.Api();
| |
|
| |
| // First get count using limit=0 which returns meta.count
| |
| api.get({
| |
| action: 'ask',
| |
| query: query +
| |
| '|limit=0' +
| |
| '|format=json',
| |
| format: 'json'
| |
| }).done(function(countData) {
| |
| // Parse count from meta.count
| |
| totalResults = 0;
| |
| totalResultsKnown = true;
| |
|
| |
| if (countData.query && countData.query.meta && countData.query.meta.count !== undefined) {
| |
| totalResults = parseInt(countData.query.meta.count, 10) || 0;
| |
| }
| |
|
| |
| // Now get actual results
| |
| api.get({
| |
| action: 'ask',
| |
| query: query +
| |
| '|?Has image#100px=image' +
| |
| '|?Has brand=brand' +
| |
| '|?Has model code=model' +
| |
| '|?Has CRT type=type' +
| |
| '|?Has screen size inches=size' +
| |
| '|?Has aspect ratio=aspect' +
| |
| '|?Has TVL=tvl' +
| |
| '|sort=' + sortProperty +
| |
| '|order=asc' +
| |
| '|limit=' + RESULTS_PER_PAGE +
| |
| '|offset=' + currentOffset +
| |
| '|format=json',
| |
| format: 'json'
| |
| }).done(function(data) {
| |
| isLoading = false;
| |
| displayResults(data);
| |
| }).fail(function(error) {
| |
| isLoading = false;
| |
| showError('Search failed. Please try again.');
| |
| console.error('SMW query error:', error);
| |
| });
| |
|
| |
| }).fail(function(error) {
| |
| // Count query failed - still try to get results
| |
| console.warn('Count query failed, proceeding without count:', error);
| |
| totalResults = 0;
| |
| totalResultsKnown = false;
| |
|
| |
| api.get({
| |
| action: 'ask',
| |
| query: query +
| |
| '|?Has image#100px=image' +
| |
| '|?Has brand=brand' +
| |
| '|?Has model code=model' +
| |
| '|?Has CRT type=type' +
| |
| '|?Has screen size inches=size' +
| |
| '|?Has aspect ratio=aspect' +
| |
| '|?Has TVL=tvl' +
| |
| '|sort=' + sortProperty +
| |
| '|order=asc' +
| |
| '|limit=' + RESULTS_PER_PAGE +
| |
| '|offset=' + currentOffset +
| |
| '|format=json',
| |
| format: 'json'
| |
| }).done(function(data) {
| |
| isLoading = false;
| |
| displayResults(data);
| |
| }).fail(function(error) {
| |
| isLoading = false;
| |
| showError('Search failed. Please try again.');
| |
| console.error('SMW query error:', error);
| |
| });
| |
| });
| |
| }
| |
|
| |
| /**
| |
| * Perform search with subobject conditions (inputs/mods)
| |
| * Uses batched queries to avoid SMW query size limits
| |
| */
| |
| function performSubobjectSearch(queryData, sortProperty) {
| |
| var api = new mw.Api();
| |
| var subConditions = queryData.subobjectConditions;
| |
|
| |
| // Build queries to find pages through their subobjects
| |
| var subQueries = [];
| |
| subConditions.forEach(function(cond) {
| |
| // Build query with main condition and any extra conditions (like feasibility)
| |
| var subQuery = '[[' + cond.property + '::' + cond.value + ']]';
| |
| if (cond.extraCondition) {
| |
| subQuery += cond.extraCondition;
| |
| }
| |
| subQuery += '|?Has CRT model|limit=5000|format=json';
| |
| subQueries.push(subQuery);
| |
| });
| |
|
| |
| // Execute subobject queries to find matching CRT models
| |
| var promises = subQueries.map(function(sq) {
| |
| return api.get({
| |
| action: 'ask',
| |
| query: sq,
| |
| format: 'json'
| |
| });
| |
| });
| |
|
| |
| Promise.all(promises).then(function(results) {
| |
| // Collect all CRT models that match
| |
| var matchingModels = new Set();
| |
| var isFirst = true;
| |
|
| |
| results.forEach(function(data) {
| |
| // Check for SMW errors
| |
| if (data.error) {
| |
| return;
| |
| }
| |
|
| |
| var currentMatches = new Set();
| |
| if (data.query && data.query.results) {
| |
| Object.values(data.query.results).forEach(function(result) {
| |
| if (result.printouts && result.printouts['Has CRT model']) {
| |
| result.printouts['Has CRT model'].forEach(function(model) {
| |
| var modelName = typeof model === 'object' ? model.fulltext : model;
| |
| currentMatches.add(modelName);
| |
| });
| |
| }
| |
| });
| |
| }
| |
|
| |
| if (isFirst) {
| |
| matchingModels = currentMatches;
| |
| isFirst = false;
| |
| } else {
| |
| // Intersection - only keep models that match ALL conditions
| |
| matchingModels = new Set([...matchingModels].filter(function(x) {
| |
| return currentMatches.has(x);
| |
| }));
| |
| }
| |
| });
| |
|
| |
| if (matchingModels.size === 0) {
| |
| isLoading = false;
| |
| totalResults = 0;
| |
| totalResultsKnown = true;
| |
| displayResults({ query: { results: {} } });
| |
| return;
| |
| }
| |
|
| |
| var matchingModelsArray = Array.from(matchingModels).sort();
| |
| totalResults = matchingModelsArray.length;
| |
| totalResultsKnown = true;
| |
|
| |
| // Check if we have additional main conditions beyond just Category:CRT models
| |
| var mainConditions = queryData.mainQuery.replace('[[Category:CRT models]]', '').trim();
| |
|
| |
| // Fetch details for the matching models using batched queries
| |
| fetchModelDetailsBatched(matchingModelsArray, mainConditions, sortProperty);
| |
|
| |
| }).catch(function(error) {
| |
| isLoading = false;
| |
| showError('Search failed. Please try again.');
| |
| console.error('Subobject query error:', error);
| |
| });
| |
| }
| |
|
| |
| /**
| |
| * Fetch model details using batched queries to avoid SMW size limits
| |
| * @param {Array} allModels - All matching model names
| |
| * @param {string} mainConditions - Additional SMW conditions (empty string if none)
| |
| * @param {string} sortProperty - Property to sort by
| |
| */
| |
| function fetchModelDetailsBatched(allModels, mainConditions, sortProperty) {
| |
| var api = new mw.Api();
| |
|
| |
| // Get just the models for the current page
| |
| var pageModels = allModels.slice(currentOffset, currentOffset + RESULTS_PER_PAGE);
| |
|
| |
| if (pageModels.length === 0) {
| |
| isLoading = false;
| |
| displayResults({ query: { results: {} } });
| |
| return;
| |
| }
| |
|
| |
| // Split into small batches to avoid SMW query size limits
| |
| // Use 15 models per batch to stay well under limits
| |
| var BATCH_SIZE = 15;
| |
| var batches = [];
| |
| for (var i = 0; i < pageModels.length; i += BATCH_SIZE) {
| |
| batches.push(pageModels.slice(i, i + BATCH_SIZE));
| |
| }
| |
|
| |
| // Build and execute batched queries
| |
| var batchPromises = batches.map(function(batch) {
| |
| // Use disjunction with || instead of separate [[]] OR [[]] to reduce query depth
| |
| var disjunction = batch.join('||');
| |
| var query = '[[' + disjunction + ']]';
| |
|
| |
| // Add main conditions if present
| |
| if (mainConditions) {
| |
| query = '[[Category:CRT models]]' + mainConditions + query;
| |
| }
| |
|
| |
| return api.get({
| |
| action: 'ask',
| |
| query: query +
| |
| '|?Has image#100px=image' +
| |
| '|?Has brand=brand' +
| |
| '|?Has model code=model' +
| |
| '|?Has CRT type=type' +
| |
| '|?Has screen size inches=size' +
| |
| '|?Has aspect ratio=aspect' +
| |
| '|?Has TVL=tvl' +
| |
| '|limit=50' +
| |
| '|format=json',
| |
| format: 'json'
| |
| });
| |
| });
| |
|
| |
| Promise.all(batchPromises).then(function(batchResults) {
| |
| // Merge results from all batches
| |
| var mergedResults = {};
| |
| var hasErrors = false;
| |
|
| |
| batchResults.forEach(function(data) {
| |
| // Check for SMW errors in the response
| |
| if (data.error) {
| |
| hasErrors = true;
| |
| return;
| |
| }
| |
|
| |
| if (data.query && data.query.results) {
| |
| Object.assign(mergedResults, data.query.results);
| |
| }
| |
| });
| |
|
| |
| if (hasErrors && Object.keys(mergedResults).length === 0) {
| |
| isLoading = false;
| |
| showError('Search query too complex. Try fewer filters.');
| |
| return;
| |
| }
| |
|
| |
| // Sort merged results by the specified property
| |
| var sortedKeys = Object.keys(mergedResults).sort(function(a, b) {
| |
| var aVal = getSortValue(mergedResults[a], sortProperty);
| |
| var bVal = getSortValue(mergedResults[b], sortProperty);
| |
| return aVal.localeCompare(bVal);
| |
| });
| |
|
| |
| var sortedResults = {};
| |
| sortedKeys.forEach(function(key) {
| |
| sortedResults[key] = mergedResults[key];
| |
| });
| |
|
| |
| isLoading = false;
| |
| displayResults({ query: { results: sortedResults } });
| |
| }).catch(function(error) {
| |
| isLoading = false;
| |
| showError('Search failed. Please try again.');
| |
| console.error('Batch query error:', error);
| |
| });
| |
| }
| |
|
| |
| /**
| |
| * Get sort value from a result for client-side sorting
| |
| */
| |
| function getSortValue(result, sortProperty) {
| |
| if (!result || !result.printouts) return '';
| |
|
| |
| var propMap = {
| |
| 'Has brand': 'brand',
| |
| 'Has model code': 'model',
| |
| 'Has screen size inches': 'size',
| |
| 'Has CRT type': 'type'
| |
| };
| |
|
| |
| var key = propMap[sortProperty] || 'brand';
| |
| var printout = result.printouts[key];
| |
|
| |
| if (!printout || !printout[0]) return '';
| |
|
| |
| var val = printout[0];
| |
| if (typeof val === 'object') {
| |
| return String(val.fulltext || val.value || '');
| |
| }
| |
| return String(val);
| |
| }
| |
|
| |
| /**
| |
| * Display search results
| |
| */
| |
| function displayResults(data) {
| |
| var grid = document.getElementById('results-grid');
| |
| var countDiv = document.getElementById('results-count');
| |
|
| |
| if (!data.query || !data.query.results || Object.keys(data.query.results).length === 0) {
| |
| grid.innerHTML = '<div class="crt-no-results">' +
| |
| '<div class="crt-no-results-icon">📺</div>' +
| |
| '<div class="crt-no-results-text">No CRTs found matching your filters.</div>' +
| |
| '<div class="crt-no-results-hint">Try adjusting your filters or <a href="#" id="reset-from-no-results">reset all filters</a>.</div>' +
| |
| '</div>';
| |
| countDiv.textContent = '0 results';
| |
| document.getElementById('pagination').innerHTML = '';
| |
|
| |
| var resetLink = document.getElementById('reset-from-no-results');
| |
| if (resetLink) {
| |
| resetLink.addEventListener('click', function(e) {
| |
| e.preventDefault();
| |
| resetFilters();
| |
| });
| |
| } | | } |
| return;
| |
| }
| |
|
| |
| var results = data.query.results;
| |
| var resultKeys = Object.keys(results);
| |
|
| |
| // Handle case where we don't know the exact total
| |
| if (!totalResultsKnown || totalResults === 0) {
| |
| // We got results but don't have a count
| |
| if (resultKeys.length === RESULTS_PER_PAGE) {
| |
| // Full page - there might be more
| |
| // Estimate based on current offset + results + 1 to indicate more
| |
| totalResults = currentOffset + resultKeys.length + 1;
| |
| totalResultsKnown = false;
| |
| } else {
| |
| // Partial page - this is all of them from this offset
| |
| totalResults = currentOffset + resultKeys.length;
| |
| totalResultsKnown = true;
| |
| }
| |
| }
| |
|
| |
| // Update count display
| |
| var startNum = currentOffset + 1;
| |
| var endNum = currentOffset + resultKeys.length;
| |
|
| |
| if (!totalResultsKnown) {
| |
| countDiv.textContent = 'Showing ' + startNum + '–' + endNum + ' of ' + totalResults + '+ CRTs';
| |
| } else {
| |
| endNum = Math.min(endNum, totalResults);
| |
| countDiv.textContent = 'Showing ' + startNum + '–' + endNum + ' of ' + totalResults + ' CRTs';
| |
| }
| |
|
| |
| var html = '';
| |
| resultKeys.forEach(function(key) {
| |
| var crt = results[key];
| |
| var printouts = crt.printouts || {};
| |
|
| |
| var title = key.replace(/_/g, ' ');
| |
| var url = crt.fullurl || mw.util.getUrl(key);
| |
| var brand = getFirstValue(printouts.brand);
| |
| var model = getFirstValue(printouts.model) || title;
| |
| var type = getFirstValue(printouts.type);
| |
| var size = getFirstValue(printouts.size);
| |
| var aspect = getFirstValue(printouts.aspect);
| |
| var tvl = getFirstValue(printouts.tvl);
| |
| var image = getImageUrl(printouts.image);
| |
|
| |
| html += '<a href="' + url + '" class="crt-result-card">';
| |
| html += '<div class="crt-card-image">';
| |
| if (image) {
| |
| html += '<img src="' + image + '" alt="' + escapeHtml(title) + '" loading="lazy">';
| |
| } else {
| |
| html += '<div class="crt-no-image"><span>📺</span></div>';
| |
| }
| |
| html += '</div>';
| |
| html += '<div class="crt-card-info">';
| |
| html += '<div class="crt-card-brand">' + escapeHtml(brand || '') + '</div>';
| |
| html += '<div class="crt-card-title">' + escapeHtml(model) + '</div>';
| |
| html += '<div class="crt-card-meta">';
| |
| if (type) html += '<span class="crt-meta-type">' + escapeHtml(type) + '</span>';
| |
| if (size) html += '<span class="crt-meta-size">' + size + '"</span>';
| |
| if (aspect) html += '<span class="crt-meta-aspect">' + escapeHtml(aspect) + '</span>';
| |
| if (tvl) html += '<span class="crt-meta-tvl">' + tvl + ' TVL</span>';
| |
| html += '</div>';
| |
| html += '</div>';
| |
| html += '</a>';
| |
| });
| |
|
| |
| grid.innerHTML = html;
| |
| grid.classList.add('grid-view');
| |
|
| |
| buildPagination(resultKeys.length);
| |
| }
| |
|
| |
| /**
| |
| * Get first value from SMW printout
| |
| */
| |
| function getFirstValue(printout) {
| |
| if (!printout || !printout[0]) return null;
| |
| var val = printout[0];
| |
| if (typeof val === 'object') {
| |
| return val.fulltext || val.value || null;
| |
| }
| |
| return val;
| |
| }
| |
|
| |
| /**
| |
| * Get image URL from SMW printout
| |
| */
| |
| function getImageUrl(printout) {
| |
| if (!printout || !printout[0]) return null;
| |
| var img = printout[0];
| |
|
| |
| // Extract filename from various formats SMW might return
| |
| var imgName;
| |
| if (typeof img === 'object') {
| |
| // SMW returns object with fulltext like "File:filename.jpg"
| |
| imgName = img.fulltext || '';
| |
| } else {
| |
| imgName = String(img);
| |
| }
| |
|
| |
| // Remove "File:" prefix if present
| |
| imgName = imgName.replace(/^File:/, '');
| |
|
| |
| if (!imgName) return null;
| |
|
| |
| // Use Special:Redirect/file to get the actual image with thumbnail
| |
| return '/wiki/Special:Redirect/file/' + encodeURIComponent(imgName) + '?width=160';
| |
| }
| |
|
| |
|
| /**
| |
| * Build pagination controls
| |
| * @param {number} currentPageResultCount - Number of results on the current page
| |
| */
| |
| function buildPagination(currentPageResultCount) {
| |
| var pagination = document.getElementById('pagination');
| |
|
| |
| // No pagination needed if we have all results on one page
| |
| if (currentOffset === 0 && currentPageResultCount < RESULTS_PER_PAGE && totalResultsKnown) {
| |
| pagination.innerHTML = '';
| |
| return;
| |
| }
| |
|
| |
| var currentPage = Math.floor(currentOffset / RESULTS_PER_PAGE) + 1;
| |
| var html = '<div class="crt-pagination-info">';
| |
|
| |
| if (totalResultsKnown && totalResults > 0) {
| |
| var totalPages = Math.ceil(totalResults / RESULTS_PER_PAGE);
| |
| html += 'Page ' + currentPage + ' of ' + totalPages;
| |
| } else {
| |
| html += 'Page ' + currentPage;
| |
| }
| |
|
| |
| html += '</div>';
| |
| html += '<div class="crt-pagination-buttons">';
| |
|
| |
| // Previous button - show if not on first page
| |
| if (currentPage > 1) {
| |
| html += '<button class="crt-page-btn crt-page-prev" data-offset="' + ((currentPage - 2) * RESULTS_PER_PAGE) + '">← Previous</button>';
| |
| }
| |
|
| |
| // Page numbers - only show if we know the total
| |
| if (totalResultsKnown && totalResults > 0) {
| |
| var totalPages = Math.ceil(totalResults / RESULTS_PER_PAGE);
| |
| var startPage = Math.max(1, currentPage - 2);
| |
| var endPage = Math.min(totalPages, currentPage + 2);
| |
|
| |
| if (startPage > 1) {
| |
| html += '<button class="crt-page-btn" data-offset="0">1</button>';
| |
| if (startPage > 2) html += '<span class="crt-page-ellipsis">…</span>';
| |
| }
| |
|
| |
| for (var i = startPage; i <= endPage; i++) {
| |
| var active = i === currentPage ? ' active' : '';
| |
| html += '<button class="crt-page-btn' + active + '" data-offset="' + ((i - 1) * RESULTS_PER_PAGE) + '">' + i + '</button>';
| |
| }
| |
|
| |
| if (endPage < totalPages) {
| |
| if (endPage < totalPages - 1) html += '<span class="crt-page-ellipsis">…</span>';
| |
| html += '<button class="crt-page-btn" data-offset="' + ((totalPages - 1) * RESULTS_PER_PAGE) + '">' + totalPages + '</button>';
| |
| }
| |
| }
| |
|
| |
| // Next button - show if we got a full page of results (there might be more)
| |
| if (currentPageResultCount === RESULTS_PER_PAGE) {
| |
| html += '<button class="crt-page-btn crt-page-next" data-offset="' + (currentPage * RESULTS_PER_PAGE) + '">Next →</button>';
| |
| }
| |
|
| |
| html += '</div>';
| |
| pagination.innerHTML = html;
| |
|
| |
| // Attach click handlers
| |
| pagination.querySelectorAll('.crt-page-btn').forEach(function(btn) {
| |
| btn.addEventListener('click', function() {
| |
| currentOffset = parseInt(this.dataset.offset, 10);
| |
| performSearch();
| |
| updateURL();
| |
|
| |
| // Scroll to top of results
| |
| document.querySelector('.crt-results-header').scrollIntoView({ behavior: 'smooth' });
| |
| });
| |
| }); | | }); |
| }
| |
|
| |
|
| /** | | }, function(error) { |
| * Update URL with current filters
| | console.error('Failed to load EveryCRT search engine:', error); |
| * Preserves the 'title' parameter if present (for index.php?title=... URLs)
| | var el = document.getElementById('advanced-search-app'); |
| */
| | if (el) { |
| function updateURL() {
| | el.innerHTML = '<div style="text-align:center;padding:64px;background:#2a2a2e;border-radius:12px;">' + |
| var filters = collectFilters(); | | '<p style="font-size:18px;color:#ddd;">Failed to load search engine.</p>' + |
| var params = new URLSearchParams(window.location.search);
| | '<p style="color:#888;font-size:13px;margin-top:12px;">Please try refreshing the page.</p></div>'; |
|
| |
| // Preserve the 'title' parameter if it exists
| |
| var titleParam = params.get('title'); | |
|
| |
| // Clear all params except 'title'
| |
| params = new URLSearchParams();
| |
| if (titleParam) { | |
| params.set('title', titleParam); | |
| }
| |
|
| |
| for (var key in filters) {
| |
| var value = filters[key];
| |
| if (Array.isArray(value)) {
| |
| params.set(key, value.join(',')); | |
| } else if (value === true) {
| |
| params.set(key, '1');
| |
| } else {
| |
| params.set(key, value); | |
| }
| |
| }
| |
|
| |
| var sortBy = document.getElementById('sort-by').value;
| |
| if (sortBy !== 'brand') {
| |
| params.set('sort', sortBy);
| |
| }
| |
|
| |
| if (currentOffset > 0) {
| |
| params.set('offset', currentOffset);
| |
| } | | } |
|
| | }); |
| var newUrl = window.location.pathname;
| |
| var paramString = params.toString();
| |
| if (paramString) {
| |
| newUrl += '?' + paramString;
| |
| }
| |
|
| |
| history.replaceState(null, '', newUrl);
| |
| }
| |
| | |
| /**
| |
| * Parse URL parameters and set filters
| |
| * This must be called AFTER filter options are loaded so that
| |
| * select dropdowns have their options available
| |
| */
| |
| function parseURLParams() {
| |
| var params = new URLSearchParams(window.location.search);
| |
|
| |
| // No URL params to process (other than possibly 'title')
| |
| var hasFilterParams = false;
| |
| params.forEach(function(value, key) {
| |
| if (key !== 'title') {
| |
| hasFilterParams = true;
| |
| }
| |
| });
| |
| if (!hasFilterParams) {
| |
| return;
| |
| }
| |
|
| |
| params.forEach(function(value, key) {
| |
| // Skip the 'title' parameter - it's just for page routing
| |
| if (key === 'title') {
| |
| return;
| |
| }
| |
| if (key === 'sort') {
| |
| document.getElementById('sort-by').value = value;
| |
| return;
| |
| }
| |
| if (key === 'offset') {
| |
| currentOffset = parseInt(value, 10) || 0;
| |
| return;
| |
| }
| |
|
| |
| // Find the filter config
| |
| var filterConfig = null;
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| if (FILTER_CONFIG[sectionKey].filters[key]) {
| |
| filterConfig = FILTER_CONFIG[sectionKey].filters[key];
| |
| break;
| |
| }
| |
| }
| |
|
| |
| if (!filterConfig) return;
| |
|
| |
| switch (filterConfig.type) {
| |
| case 'select':
| |
| var select = document.getElementById('filter-' + key);
| |
| if (select) {
| |
| // Verify the option exists before setting
| |
| var optionExists = Array.from(select.options).some(function(opt) {
| |
| return opt.value === value;
| |
| });
| |
| if (optionExists) {
| |
| select.value = value;
| |
| } else {
| |
| console.warn('Filter option not found:', key, value);
| |
| }
| |
| }
| |
| break;
| |
| case 'number':
| |
| var input = document.getElementById('filter-' + key);
| |
| if (input) input.value = value;
| |
| break;
| |
| case 'checkbox':
| |
| var checkbox = document.getElementById('filter-' + key);
| |
| if (checkbox) checkbox.checked = (value === '1' || value === 'true');
| |
| break;
| |
| case 'multiselect':
| |
| var ms = document.getElementById('filter-' + key);
| |
| if (ms) {
| |
| var values = value.split(',');
| |
| var selectedContainer = ms.querySelector('.crt-multiselect-selected');
| |
| values.forEach(function(v) {
| |
| v = v.trim();
| |
| if (!v) return;
| |
|
| |
| // Check if tag already exists to avoid duplicates
| |
| var existingTag = selectedContainer.querySelector('[data-value="' + CSS.escape(v) + '"]');
| |
| if (existingTag) return;
| |
|
| |
| var tag = document.createElement('span');
| |
| tag.className = 'crt-multiselect-tag';
| |
| tag.dataset.value = v;
| |
| tag.innerHTML = escapeHtml(v) + ' <span class="crt-tag-remove">×</span>';
| |
| tag.querySelector('.crt-tag-remove').addEventListener('click', function(e) {
| |
| e.stopPropagation();
| |
| tag.remove();
| |
| triggerSearch(true);
| |
| });
| |
| selectedContainer.appendChild(tag);
| |
| });
| |
| }
| |
| break;
| |
| }
| |
| });
| |
| }
| |
| | |
| /**
| |
| * Show loading state
| |
| */
| |
| function showLoading() {
| |
| var grid = document.getElementById('results-grid');
| |
| grid.innerHTML = '<div class="crt-loading">' +
| |
| '<div class="crt-spinner"></div>' +
| |
| '<div class="crt-loading-text">Searching...</div>' +
| |
| '</div>';
| |
| }
| |
| | |
| /**
| |
| * Show error message
| |
| */
| |
| function showError(message) {
| |
| var grid = document.getElementById('results-grid');
| |
| grid.innerHTML = '<div class="crt-error">' +
| |
| '<div class="crt-error-icon">⚠️</div>' +
| |
| '<div class="crt-error-text">' + escapeHtml(message) + '</div>' +
| |
| '</div>';
| |
| document.getElementById('pagination').innerHTML = '';
| |
| } | |
| | |
| /**
| |
| * Escape HTML entities
| |
| */
| |
| function escapeHtml(str) {
| |
| if (!str) return '';
| |
| return String(str)
| |
| .replace(/&/g, '&')
| |
| .replace(/</g, '<')
| |
| .replace(/>/g, '>')
| |
| .replace(/"/g, '"')
| |
| .replace(/'/g, ''');
| |
| }
| |
|
| |
|
| // Initialize when DOM is ready
| |
| if (document.readyState === 'loading') {
| |
| document.addEventListener('DOMContentLoaded', init);
| |
| } else {
| |
| init();
| |
| }
| |
| })(); | | })(); |