|
|
| (11 intermediate revisions by the same user not shown) |
| Line 1: |
Line 1: |
| /** | | /** |
| * EveryCRT AV Device Search | | * EveryCRT AV Device Search — page-specific configuration |
| * Server-side faceted search for AV devices using Semantic MediaWiki API | | * Loads the shared search engine and passes AV device-specific filters, printouts, and card layout. |
| */ | | */ |
| (function() { | | (function() { |
| 'use strict'; | | 'use strict'; |
|
| |
|
| var RESULTS_PER_PAGE = 48; | | mw.loader.getScript( |
| var currentOffset = 0;
| | mw.config.get('wgScript') + '?title=MediaWiki:EveryCRT-SearchEngine.js&action=raw&ctype=text/javascript' |
| var filterOptions = {};
| | ).then(function() { |
| var totalResults = 0;
| |
| var totalResultsKnown = true; | |
| var isLoading = false;
| |
| var searchDebounceTimer = null;
| |
| var DEBOUNCE_DELAY = 400;
| |
|
| |
|
| // Filter configuration
| | window.EveryCRTSearch({ |
| var FILTER_CONFIG = {
| |
| general: {
| |
| label: 'General',
| |
| collapsed: false,
| |
| filters: {
| |
| brand: {
| |
| label: 'Brand',
| |
| type: 'select',
| |
| property: 'Has brand',
| |
| loadFrom: 'category',
| |
| category: 'Manufacturers'
| |
| },
| |
| device_type: {
| |
| label: 'Device Type',
| |
| type: 'select',
| |
| property: 'Has device type',
| |
| loadFrom: 'static',
| |
| values: ['Character generator', 'Distribution amplifier', 'Editing controller', 'Frame synchronizer', 'Scan converter', 'Time base corrector', 'VCR', 'VCR/DVD combo', 'VCR/DVD-R combo', 'VCR/HDD combo', 'Video mixer', 'Video player', 'Video processor', 'VTR', 'Other']
| |
| },
| |
| format_family: {
| |
| label: 'Format Family',
| |
| type: 'select',
| |
| property: 'Has format family',
| |
| loadFrom: 'static',
| |
| values: ['Betamax', 'DV', 'U-matic', 'VHS', 'Video8']
| |
| }
| |
| }
| |
| }, | |
| tbc: {
| |
| label: 'TBC Capabilities',
| |
| collapsed: false,
| |
| filters: {
| |
| has_line_tbc: {
| |
| label: 'Line TBC',
| |
| type: 'checkbox',
| |
| property: 'TBC type',
| |
| containsValue: 'Line TBC'
| |
| },
| |
| has_frame_tbc: {
| |
| label: 'Frame TBC',
| |
| type: 'checkbox',
| |
| property: 'TBC type',
| |
| containsValue: 'Frame TBC'
| |
| }
| |
| }
| |
| }
| |
| };
| |
|
| |
|
| function triggerSearch(immediate) {
| | containerId: 'av-search-app', |
| if (searchDebounceTimer) {
| | category: 'AV devices', |
| clearTimeout(searchDebounceTimer); | | resultLabel: 'devices', |
| }
| | noImageEmoji: '\uD83D\uDCFC', |
| if (immediate) {
| | defaultSort: 'brand', |
| currentOffset = 0; | |
| performSearch(); | |
| updateURL(); | |
| } else {
| |
| searchDebounceTimer = setTimeout(function() {
| |
| currentOffset = 0;
| |
| performSearch();
| |
| updateURL();
| |
| }, DEBOUNCE_DELAY); | |
| }
| |
| }
| |
|
| |
|
| function init() {
| | // ── Sort options ─────────────────────────────────────── |
| var container = document.getElementById('av-search-app');
| | sortOptions: [ |
| if (!container) return;
| | { value: 'brand', label: 'Brand', property: 'Has brand', printoutKey: 'brand' }, |
| | { value: 'model', label: 'Model', property: 'Has model code', printoutKey: 'model' }, |
| | { value: 'device_type', label: 'Device Type', property: 'Has device type', printoutKey: 'device_type' } |
| | ], |
|
| |
|
| container.innerHTML = buildHTML();
| | // ── Extra printouts (image, brand, model are always included) ── |
| attachEventListeners();
| | printouts: [ |
| loadFilterOptions();
| | { property: 'Has device type', key: 'device_type' }, |
| }
| | { property: 'Has format family', key: 'format_family' }, |
| | { property: 'Has supported formats', key: 'supported_formats' } |
| | ], |
|
| |
|
| function buildHTML() {
| | // ── Card meta fields ─────────────────────────────────── |
| var html = '<div class="crt-search-wrapper">';
| | cardMeta: [ |
| | { key: 'device_type', cssClass: 'crt-meta-type' }, |
| | { key: 'format_family' } |
| | ], |
|
| |
|
| // Notifications
| | // ── Filter configuration ─────────────────────────────── |
| html += '<div class="crt-notifications" id="notifications"></div>';
| | filterConfig: { |
| | | general: { |
| // Mobile filter toggle
| | label: 'General', |
| html += '<button type="button" class="crt-mobile-filter-toggle" id="mobile-filter-toggle">';
| | collapsed: false, |
| html += '<span class="crt-filter-icon">☰</span> Filters';
| | filters: { |
| html += '</button>';
| | brand: { |
| | | label: 'Brand', |
| // Filters sidebar
| | type: 'select', |
| html += '<div class="crt-search-filters" id="filters-sidebar">';
| | property: 'Has brand', |
| html += '<div class="crt-filters-header">';
| | loadFrom: 'smw', |
| html += '<h2>Filters</h2>';
| | smwQuery: '[[Is AV brand category::Yes]]', |
| html += '<button type="button" class="crt-reset-btn" id="reset-filters">Reset All</button>';
| | smwProperty: 'Brand name' |
| html += '</div>';
| | }, |
| | | device_type: { |
| html += '<div class="crt-filters-body">';
| | label: 'Device Type', |
| for (var sectionKey in FILTER_CONFIG) {
| | type: 'select', |
| html += buildFilterSection(sectionKey, FILTER_CONFIG[sectionKey]);
| | property: 'Has device type', |
| }
| | loadFrom: 'smw', |
| html += '</div>';
| | smwQuery: '[[Is AV device type category::Yes]]', |
| | | smwProperty: 'Device type name' |
| html += '<div class="crt-apply-filters">';
| | }, |
| html += '<button type="button" class="crt-apply-btn" id="apply-filters">Apply Filters</button>';
| | format_family: { |
| html += '</div>';
| | label: 'Format Family', |
| html += '</div>';
| | type: 'select', |
| | | property: 'Has format family', |
| // Results area
| | loadFrom: 'smw', |
| html += '<div class="crt-search-results">';
| | smwQuery: '[[Category:AV devices]][[Has format family::+]]', |
| html += '<div class="crt-results-header">';
| | smwProperty: 'Has format family' |
| html += '<div class="crt-results-count" id="results-count">Loading...</div>';
| | }, |
| html += '<div class="crt-results-controls">';
| | plays: { |
| html += '<div class="crt-results-sort">';
| | label: 'Plays', |
| html += '<label for="sort-by">Sort by:</label>';
| | type: 'select', |
| html += '<select id="sort-by">';
| | property: 'Has supported formats', |
| html += '<option value="brand">Brand</option>';
| | loadFrom: 'smw', |
| html += '<option value="model">Model</option>';
| | smwQuery: '[[Category:AV devices]][[Has supported formats::+]]', |
| html += '<option value="device_type">Device Type</option>';
| | smwProperty: 'Has supported formats' |
| 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;
| |
| }
| |
| | |
| function buildFilterSection(sectionKey, section) {
| |
| var collapsed = section.collapsed ? ' collapsed' : '';
| |
| var html = '<div class="crt-filter-section' + collapsed + '" data-section="' + sectionKey + '">';
| |
| html += '<div class="crt-section-header">';
| |
| html += '<span class="crt-section-title">' + section.label + '</span>';
| |
| html += '<span class="crt-section-toggle">▼</span>';
| |
| html += '</div>';
| |
| html += '<div class="crt-section-content">';
| |
| for (var filterKey in section.filters) {
| |
| html += buildFilterInput(filterKey, section.filters[filterKey]);
| |
| }
| |
| html += '</div></div>';
| |
| return html;
| |
| }
| |
| | |
| function buildFilterInput(filterKey, filter) {
| |
| var html = '<div class="crt-filter-item" data-filter="' + filterKey + '">';
| |
| switch (filter.type) {
| |
| case 'select':
| |
| html += '<label for="filter-' + filterKey + '">' + filter.label + '</label>';
| |
| html += '<select id="filter-' + filterKey + '" class="crt-filter-select" data-filter="' + filterKey + '">';
| |
| html += '<option value="">All</option>';
| |
| if (filter.loadFrom === 'static' && filter.values) {
| |
| for (var i = 0; i < filter.values.length; i++) {
| |
| html += '<option value="' + filter.values[i] + '">' + filter.values[i] + '</option>';
| |
| } | | } |
| } | | }, |
| html += '</select>'; | | tbc: { |
| break;
| | label: 'TBC Capabilities', |
| case 'checkbox':
| | collapsed: false, |
| html += '<label class="crt-checkbox-label">';
| | filters: { |
| html += '<input type="checkbox" id="filter-' + filterKey + '" class="crt-filter-checkbox" data-filter="' + filterKey + '">';
| | has_line_tbc: { |
| html += '<span>' + filter.label + '</span>';
| | label: 'Line TBC', |
| html += '</label>';
| | type: 'checkbox', |
| break;
| | property: 'TBC type', |
| }
| | containsValue: 'Line TBC' |
| html += '</div>';
| | }, |
| return html;
| | has_frame_tbc: { |
| }
| | label: 'Frame synchronizer', |
| | | type: 'checkbox', |
| function showNotification(message, type, duration) {
| | property: 'TBC type', |
| var container = document.getElementById('notifications');
| | containsValue: 'Frame synchronizer' |
| if (!container) return;
| | } |
| type = type || 'info';
| |
| duration = duration !== undefined ? duration : 8000;
| |
| var notification = document.createElement('div');
| |
| notification.className = 'crt-notification crt-notification-' + type;
| |
| var icon = type === 'error' ? '⚠️' : type === 'warning' ? '⚠️' : 'ℹ️';
| |
| notification.innerHTML = '<span class="crt-notification-icon">' + icon + '</span>' +
| |
| '<span class="crt-notification-message">' + escapeHtml(message) + '</span>' +
| |
| '<button class="crt-notification-close" title="Dismiss">×</button>';
| |
| notification.querySelector('.crt-notification-close').addEventListener('click', function() {
| |
| notification.remove();
| |
| });
| |
| container.appendChild(notification);
| |
| if (duration > 0) {
| |
| setTimeout(function() { if (notification.parentNode) notification.remove(); }, duration);
| |
| }
| |
| }
| |
| | |
| function attachEventListeners() {
| |
| document.getElementById('mobile-filter-toggle').addEventListener('click', function() {
| |
| document.getElementById('filters-sidebar').classList.toggle('mobile-open');
| |
| this.classList.toggle('active');
| |
| });
| |
| | |
| document.querySelectorAll('#av-search-app .crt-section-header').forEach(function(header) {
| |
| header.addEventListener('click', function() {
| |
| this.closest('.crt-filter-section').classList.toggle('collapsed');
| |
| });
| |
| });
| |
| | |
| document.getElementById('apply-filters').addEventListener('click', function() {
| |
| currentOffset = 0;
| |
| performSearch();
| |
| updateURL();
| |
| document.getElementById('filters-sidebar').classList.remove('mobile-open');
| |
| document.getElementById('mobile-filter-toggle').classList.remove('active');
| |
| });
| |
| | |
| document.getElementById('reset-filters').addEventListener('click', resetFilters);
| |
| | |
| document.getElementById('sort-by').addEventListener('change', function() {
| |
| triggerSearch(true);
| |
| });
| |
| | |
| document.querySelectorAll('#av-search-app .crt-view-btn').forEach(function(btn) {
| |
| btn.addEventListener('click', function() {
| |
| document.querySelectorAll('#av-search-app .crt-view-btn').forEach(function(b) { b.classList.remove('active'); });
| |
| this.classList.add('active');
| |
| var grid = document.getElementById('results-grid');
| |
| grid.classList.remove('grid-view', 'list-view');
| |
| grid.classList.add(this.dataset.view + '-view');
| |
| });
| |
| });
| |
| | |
| document.querySelectorAll('#av-search-app .crt-filter-select').forEach(function(select) {
| |
| select.addEventListener('change', function() { triggerSearch(true); });
| |
| });
| |
| | |
| document.querySelectorAll('#av-search-app .crt-filter-checkbox').forEach(function(checkbox) {
| |
| checkbox.addEventListener('change', function() { triggerSearch(true); });
| |
| });
| |
| }
| |
| | |
| 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() {
| |
| populateSelectFilters();
| |
| parseURLParams();
| |
| performSearch();
| |
| });
| |
| }
| |
| | |
| 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();
| |
| });
| |
| });
| |
| }
| |
| | |
| 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;
| |
| }
| |
| }
| |
| }
| |
| }
| |
| }
| |
| | |
| function collectFilters() {
| |
| var filters = {};
| |
| document.querySelectorAll('#av-search-app .crt-filter-select').forEach(function(input) {
| |
| var value = input.value.trim();
| |
| if (value) filters[input.dataset.filter] = value;
| |
| });
| |
| document.querySelectorAll('#av-search-app .crt-filter-checkbox').forEach(function(input) {
| |
| if (input.checked) filters[input.dataset.filter] = true;
| |
| });
| |
| return filters;
| |
| }
| |
| | |
| function resetFilters() {
| |
| document.querySelectorAll('#av-search-app .crt-filter-select').forEach(function(input) { input.value = ''; });
| |
| document.querySelectorAll('#av-search-app .crt-filter-checkbox').forEach(function(input) { input.checked = false; });
| |
| currentOffset = 0;
| |
| 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();
| |
| }
| |
| | |
| 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 (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></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></span>';
| |
| }
| |
| }
| |
| | |
| if (hasFilters) {
| |
| html += '<button class="crt-clear-all-filters">Clear All</button>';
| |
| }
| |
| | |
| container.innerHTML = html;
| |
| | |
| container.querySelectorAll('.crt-active-filter').forEach(function(af) {
| |
| af.querySelector('.crt-af-remove').addEventListener('click', function() {
| |
| removeActiveFilter(af.dataset.filter);
| |
| });
| |
| });
| |
| | |
| var clearAll = container.querySelector('.crt-clear-all-filters');
| |
| if (clearAll) clearAll.addEventListener('click', resetFilters);
| |
| }
| |
| | |
| function removeActiveFilter(filterKey) {
| |
| 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;
| |
| if (filterConfig.type === 'select') {
| |
| document.getElementById('filter-' + filterKey).value = '';
| |
| } else if (filterConfig.type === 'checkbox') {
| |
| document.getElementById('filter-' + filterKey).checked = false;
| |
| }
| |
| triggerSearch(true);
| |
| }
| |
| | |
| function getFilterLabel(filterKey) {
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| if (FILTER_CONFIG[sectionKey].filters[filterKey]) {
| |
| return FILTER_CONFIG[sectionKey].filters[filterKey].label;
| |
| }
| |
| }
| |
| return filterKey;
| |
| }
| |
| | |
| function buildSMWQuery() {
| |
| var filters = collectFilters();
| |
| var conditions = ['[[Category:AV devices]]'];
| |
| | |
| 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.type === 'select' && filter.property) {
| |
| conditions.push('[[' + filter.property + '::' + value + ']]');
| |
| } else if (filter.type === 'checkbox' && filter.property && value === true) {
| |
| if (filter.containsValue) {
| |
| conditions.push('[[' + filter.property + '::~*' + filter.containsValue + '*]]');
| |
| } | | } |
| } | | } |
| } | | } |
| }
| |
|
| |
|
| return conditions.join('');
| |
| }
| |
|
| |
| function performSearch() {
| |
| if (isLoading) return;
| |
| isLoading = true;
| |
| showLoading();
| |
| updateActiveFilters();
| |
|
| |
| var query = buildSMWQuery();
| |
| var sortBy = document.getElementById('sort-by').value;
| |
|
| |
| var sortProperty = 'Has brand';
| |
| switch (sortBy) {
| |
| case 'model': sortProperty = 'Has model code'; break;
| |
| case 'device_type': sortProperty = 'Has device type'; break;
| |
| }
| |
|
| |
| var api = new mw.Api();
| |
|
| |
| // Get count first
| |
| api.get({
| |
| action: 'ask',
| |
| query: query + '|limit=0|format=json',
| |
| format: 'json'
| |
| }).done(function(countData) {
| |
| totalResults = 0;
| |
| totalResultsKnown = true;
| |
| if (countData.query && countData.query.meta && countData.query.meta.count !== undefined) {
| |
| totalResults = parseInt(countData.query.meta.count, 10) || 0;
| |
| }
| |
|
| |
| api.get({
| |
| action: 'ask',
| |
| query: query +
| |
| '|?Has image#100px=image' +
| |
| '|?Has brand=brand' +
| |
| '|?Has model code=model' +
| |
| '|?Has device type=device_type' +
| |
| '|?Has format family=format_family' +
| |
| '|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.');
| |
| });
| |
| }).fail(function() {
| |
| totalResults = 0;
| |
| totalResultsKnown = false;
| |
| api.get({
| |
| action: 'ask',
| |
| query: query +
| |
| '|?Has image#100px=image' +
| |
| '|?Has brand=brand' +
| |
| '|?Has model code=model' +
| |
| '|?Has device type=device_type' +
| |
| '|?Has format family=format_family' +
| |
| '|sort=' + sortProperty +
| |
| '|order=asc' +
| |
| '|limit=' + RESULTS_PER_PAGE +
| |
| '|offset=' + currentOffset +
| |
| '|format=json',
| |
| format: 'json'
| |
| }).done(function(data) {
| |
| isLoading = false;
| |
| displayResults(data);
| |
| }).fail(function() {
| |
| isLoading = false;
| |
| showError('Search failed. Please try again.');
| |
| });
| |
| }); | | }); |
| }
| |
|
| |
| 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) { | | }, function(error) { |
| grid.innerHTML = '<div class="crt-no-results">' + | | console.error('Failed to load EveryCRT search engine:', error); |
| '<div class="crt-no-results-icon">📼</div>' + | | var el = document.getElementById('av-search-app'); |
| '<div class="crt-no-results-text">No AV devices found matching your filters.</div>' +
| | if (el) { |
| '<div class="crt-no-results-hint">Try adjusting your filters or <a href="#" id="reset-from-no-results">reset all filters</a>.</div>' + | | el.innerHTML = '<div style="text-align:center;padding:64px;background:#2a2a2e;border-radius:12px;">' + |
| '</div>';
| | '<p style="font-size:18px;color:#ddd;">Failed to load search engine.</p>' + |
| countDiv.textContent = '0 results';
| | '<p style="color:#888;font-size:13px;margin-top:12px;">Please try refreshing the page.</p></div>'; |
| 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);
| |
|
| |
| if (!totalResultsKnown || totalResults === 0) {
| |
| if (resultKeys.length === RESULTS_PER_PAGE) {
| |
| totalResults = currentOffset + resultKeys.length + 1;
| |
| totalResultsKnown = false;
| |
| } else {
| |
| totalResults = currentOffset + resultKeys.length;
| |
| totalResultsKnown = true;
| |
| }
| |
| }
| |
|
| |
| var startNum = currentOffset + 1;
| |
| var endNum = currentOffset + resultKeys.length;
| |
| if (!totalResultsKnown) {
| |
| countDiv.textContent = 'Showing ' + startNum + '\u2013' + endNum + ' of ' + totalResults + '+ devices';
| |
| } else {
| |
| endNum = Math.min(endNum, totalResults);
| |
| countDiv.textContent = 'Showing ' + startNum + '\u2013' + endNum + ' of ' + totalResults + ' devices';
| |
| }
| |
|
| |
| var html = '';
| |
| resultKeys.forEach(function(key) {
| |
| var device = results[key];
| |
| var printouts = device.printouts || {};
| |
| var title = key.replace(/_/g, ' ');
| |
| var url = device.fullurl || mw.util.getUrl(key);
| |
| var brand = getFirstValue(printouts.brand);
| |
| var model = getFirstValue(printouts.model) || title;
| |
| var deviceType = getFirstValue(printouts.device_type);
| |
| var formatFamily = getFirstValue(printouts.format_family);
| |
| 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>\uD83D\uDCFC</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 (deviceType) html += '<span class="crt-meta-type">' + escapeHtml(deviceType) + '</span>';
| |
| if (formatFamily) html += '<span>' + escapeHtml(formatFamily) + '</span>';
| |
| html += '</div>';
| |
| html += '</div>';
| |
| html += '</a>';
| |
| });
| |
|
| |
| grid.innerHTML = html;
| |
| grid.classList.add('grid-view');
| |
| buildPagination(resultKeys.length);
| |
| }
| |
|
| |
| function getFirstValue(printout) {
| |
| if (!printout || !printout[0]) return null;
| |
| var val = printout[0];
| |
| return typeof val === 'object' ? (val.fulltext || val.value || null) : val;
| |
| }
| |
|
| |
| function getImageUrl(printout) {
| |
| if (!printout || !printout[0]) return null;
| |
| var img = printout[0];
| |
| var imgName = typeof img === 'object' ? (img.fulltext || '') : String(img);
| |
| imgName = imgName.replace(/^File:/, '');
| |
| if (!imgName) return null;
| |
| return '/wiki/Special:Redirect/file/' + encodeURIComponent(imgName) + '?width=160';
| |
| }
| |
|
| |
| function buildPagination(currentPageResultCount) {
| |
| var pagination = document.getElementById('pagination');
| |
| 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><div class="crt-pagination-buttons">';
| |
|
| |
| if (currentPage > 1) {
| |
| html += '<button class="crt-page-btn crt-page-prev" data-offset="' + ((currentPage - 2) * RESULTS_PER_PAGE) + '">\u2190 Previous</button>';
| |
| }
| |
|
| |
| 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">\u2026</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">\u2026</span>';
| |
| html += '<button class="crt-page-btn" data-offset="' + ((totalPages - 1) * RESULTS_PER_PAGE) + '">' + totalPages + '</button>';
| |
| }
| |
| }
| |
|
| |
| if (currentPageResultCount === RESULTS_PER_PAGE) {
| |
| html += '<button class="crt-page-btn crt-page-next" data-offset="' + (currentPage * RESULTS_PER_PAGE) + '">Next \u2192</button>';
| |
| }
| |
|
| |
| html += '</div>';
| |
| pagination.innerHTML = html;
| |
|
| |
| pagination.querySelectorAll('.crt-page-btn').forEach(function(btn) {
| |
| btn.addEventListener('click', function() {
| |
| currentOffset = parseInt(this.dataset.offset, 10);
| |
| performSearch();
| |
| updateURL();
| |
| document.querySelector('.crt-results-header').scrollIntoView({ behavior: 'smooth' });
| |
| });
| |
| });
| |
| }
| |
|
| |
| function updateURL() {
| |
| var filters = collectFilters();
| |
| var params = new URLSearchParams(window.location.search);
| |
| var titleParam = params.get('title');
| |
| params = new URLSearchParams();
| |
| if (titleParam) params.set('title', titleParam);
| |
|
| |
| for (var key in filters) {
| |
| var value = filters[key];
| |
| 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);
| |
| }
| |
|
| |
| function parseURLParams() {
| |
| var params = new URLSearchParams(window.location.search);
| |
| var invalidFilters = [];
| |
| var hasFilterParams = false;
| |
| params.forEach(function(value, key) {
| |
| if (key !== 'title') hasFilterParams = true;
| |
| });
| |
| if (!hasFilterParams) return;
| |
|
| |
| params.forEach(function(value, key) {
| |
| if (key === 'title') return;
| |
| if (key === 'sort') {
| |
| document.getElementById('sort-by').value = value;
| |
| return;
| |
| }
| |
| if (key === 'offset') {
| |
| currentOffset = parseInt(value, 10) || 0;
| |
| return;
| |
| }
| |
|
| |
| var filterConfig = null;
| |
| for (var sectionKey in FILTER_CONFIG) {
| |
| if (FILTER_CONFIG[sectionKey].filters[key]) {
| |
| filterConfig = FILTER_CONFIG[sectionKey].filters[key];
| |
| break;
| |
| }
| |
| }
| |
|
| |
| if (!filterConfig) {
| |
| invalidFilters.push({ key: key, reason: 'Unknown filter "' + key + '"' });
| |
| return;
| |
| }
| |
|
| |
| switch (filterConfig.type) {
| |
| case 'select':
| |
| var select = document.getElementById('filter-' + key);
| |
| if (select) {
| |
| var optionExists = Array.from(select.options).some(function(opt) { return opt.value === value; });
| |
| if (optionExists) {
| |
| select.value = value;
| |
| } else {
| |
| invalidFilters.push({ key: key, reason: '"' + value + '" is not a valid option for ' + filterConfig.label });
| |
| }
| |
| }
| |
| break;
| |
| case 'checkbox':
| |
| var checkbox = document.getElementById('filter-' + key);
| |
| if (checkbox) checkbox.checked = (value === '1' || value === 'true');
| |
| break;
| |
| }
| |
| });
| |
|
| |
| if (invalidFilters.length > 0) {
| |
| invalidFilters.forEach(function(invalid) {
| |
| showNotification(invalid.reason + '. This filter was ignored.', 'warning', 10000);
| |
| });
| |
| }
| |
| }
| |
|
| |
| 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>';
| |
| }
| |
|
| |
| function showError(message) {
| |
| var grid = document.getElementById('results-grid');
| |
| grid.innerHTML = '<div class="crt-error">' +
| |
| '<div class="crt-error-icon">\u26A0\uFE0F</div>' +
| |
| '<div class="crt-error-text">' + escapeHtml(message) + '</div>' +
| |
| '</div>';
| |
| document.getElementById('pagination').innerHTML = '';
| |
| }
| |
|
| |
| function escapeHtml(str) {
| |
| if (!str) return '';
| |
| return String(str)
| |
| .replace(/&/g, '&')
| |
| .replace(/</g, '<')
| |
| .replace(/>/g, '>')
| |
| .replace(/"/g, '"')
| |
| .replace(/'/g, ''');
| |
| }
| |
|
| |
| if (document.readyState === 'loading') {
| |
| document.addEventListener('DOMContentLoaded', init);
| |
| } else {
| |
| init();
| |
| }
| |
| })(); | | })(); |