Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
* EveryCRT AV Device Search
* Server-side faceted search for AV devices using Semantic MediaWiki API
*/
(function() {
'use strict';
var RESULTS_PER_PAGE = 48;
var currentOffset = 0;
var filterOptions = {};
var totalResults = 0;
var totalResultsKnown = true;
var isLoading = false;
var searchDebounceTimer = null;
var DEBOUNCE_DELAY = 400;
// Filter configuration
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) {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
if (immediate) {
currentOffset = 0;
performSearch();
updateURL();
} else {
searchDebounceTimer = setTimeout(function() {
currentOffset = 0;
performSearch();
updateURL();
}, DEBOUNCE_DELAY);
}
}
function init() {
var container = document.getElementById('av-search-app');
if (!container) return;
container.innerHTML = buildHTML();
attachEventListeners();
loadFilterOptions();
}
function buildHTML() {
var html = '<div class="crt-search-wrapper">';
// Notifications
html += '<div class="crt-notifications" id="notifications"></div>';
// 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>';
html += '<div class="crt-filters-body">';
for (var sectionKey in FILTER_CONFIG) {
html += buildFilterSection(sectionKey, FILTER_CONFIG[sectionKey]);
}
html += '</div>';
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="device_type">Device 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;
}
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>';
break;
case 'checkbox':
html += '<label class="crt-checkbox-label">';
html += '<input type="checkbox" id="filter-' + filterKey + '" class="crt-filter-checkbox" data-filter="' + filterKey + '">';
html += '<span>' + filter.label + '</span>';
html += '</label>';
break;
}
html += '</div>';
return html;
}
function showNotification(message, type, duration) {
var container = document.getElementById('notifications');
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) {
grid.innerHTML = '<div class="crt-no-results">' +
'<div class="crt-no-results-icon">📼</div>' +
'<div class="crt-no-results-text">No AV devices 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);
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();
}
})();