MediaWiki:Common.js
Увага: Після публікування слід очистити кеш браузера, щоб побачити зміни.
- Firefox / Safari: тримайте Shift, коли натискаєте Оновити, або натисніть Ctrl-F5 чи Ctrl-Shift-R (⌘-R на Apple Mac)
- Google Chrome: натисніть Ctrl-Shift-R (⌘-Shift-R на Apple Mac)
- Edge: тримайте Ctrl, коли натискаєте Оновити, або натисніть Ctrl-F5.
/* ── 1. Налаштування Viewport та базові функції ── */
(function() {
var viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
} else {
var meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, initial-scale=1.0';
document.head.appendChild(meta);
}
})();
/**
* Глобальні допоміжні функції для парсингу контенту
* (Винесено вгору, щоб були доступні всім частинам скрипта)
*/
function extractImageName(wikitext) {
if (!wikitext) return null;
var m = wikitext.match(/\|\s*image\s*=\s*([^\|\n\}]+)/i);
if (m && m[1].trim()) return m[1].trim();
m = wikitext.match(/\[\[(?:Файл|File|Зображення|Image):([^\|\]]+)/i);
return m ? m[1].trim() : null;
}
function extractDescription(wikitext) {
if (!wikitext) return '';
var text = wikitext;
/* Прибрати інфобокс */
var depth = 0, end = -1;
for (var i = 0; i < text.length; i++) {
if (text[i] === '{' && text[i + 1] === '{') { depth++; i++; }
else if (text[i] === '}' && text[i + 1] === '}') {
depth--;
if (depth === 0) { end = i + 2; break; }
i++;
}
}
if (end > 0) text = text.substring(end);
text = text
.replace(/\[\[(?:Файл|File|Зображення|Image):[^\]]*\]\]/gi, '')
.replace(/\{\|[\s\S]*?\|\}/g, '')
.replace(/\{\{[^}]*\}\}/g, '')
.replace(/={2,6}[^=\n]+=+/g, '')
.replace(/\[\[(?:[^\|\]]*\|)?([^\]]+)\]\]/g, '$1')
.replace(/\[[^\s\]]+\s+([^\]]+)\]/g, '$1')
.replace(/\[[^\]]+\]/g, '')
.replace(/'{2,3}/g, '')
.replace(/<[^>]+>/g, '')
.replace(/^[\*#:;].*/gm, '');
var lines = text.split('\n')
.map(function (l) { return l.trim(); })
.filter(function (l) { return l.length > 20; });
if (!lines.length) return '';
var result = lines[0].substring(0, 160);
return lines[0].length > 160 ? result + '…' : result;
}
/* ── 2. Обробка кліків по картках (jQuery) ── */
$(function() {
$(document).on('click', '.category-card', function(e) {
var $card = $(this);
var href = $card.attr('data-href');
if (href && $(e.target).closest('a').length === 0) {
window.location.href = href;
}
});
});
/* ── 3. Випадкові статті на головній ── */
function loadRandomArticles() {
var list = document.getElementById('random-articles-list');
if (!list) return;
list.innerHTML = '<div class="random-articles-loading">Завантаження...</div>';
var apiBase = mw.config.get('wgScriptPath') + '/api.php';
var excludedPages = ['Головна сторінка', 'Структурні підрозділи', 'Викладачі'];
fetch(apiBase + '?action=query&list=random&rnnamespace=0&rnlimit=10&format=json')
.then(function (r) { return r.json(); })
.then(function (data) {
var pages = data.query.random
.filter(function (p) { return excludedPages.indexOf(p.title) === -1; })
.slice(0, 3);
var titles = pages.map(function (p) { return p.title; }).join('|');
return fetch(
apiBase + '?action=query' +
'&titles=' + encodeURIComponent(titles) +
'&prop=revisions&rvprop=content&format=json'
);
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.query || !data.query.pages) return [];
var pages = Object.values(data.query.pages);
var promises = pages.map(function (page) {
var title = page.title;
var pageUrl = mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(title.replace(/ /g, '_')));
var content = (page.revisions && page.revisions[0]) ? (page.revisions[0]['*'] || '') : '';
var excerpt = extractDescription(content) || 'Немає опису.';
var imgName = extractImageName(content);
if (imgName) {
var fileTitle = /^(Файл|File|Image|Зображення):/i.test(imgName) ? imgName : 'File:' + imgName;
return fetch(apiBase + '?action=query&titles=' + encodeURIComponent(fileTitle) + '&prop=imageinfo&iiprop=url&iiurlwidth=120&format=json')
.then(function (r) { return r.json(); })
.then(function (imgData) {
var ip = Object.values(imgData.query.pages);
var ii = ip[0] && ip[0].imageinfo && ip[0].imageinfo[0];
return { title: title, excerpt: excerpt, pageUrl: pageUrl, imgSrc: ii ? (ii.thumburl || ii.url) : null };
})
.catch(function () { return { title: title, excerpt: excerpt, pageUrl: pageUrl, imgSrc: null }; });
}
return Promise.resolve({ title: title, excerpt: excerpt, pageUrl: pageUrl, imgSrc: null });
});
return Promise.all(promises);
})
.then(function (results) {
list.innerHTML = '';
results.forEach(function (item) {
var thumbHtml = item.imgSrc
? '<img class="random-article-thumb" src="' + item.imgSrc + '" alt="">'
: '<div class="random-article-thumb-placeholder">📄</div>';
var card = document.createElement('a');
card.href = item.pageUrl;
card.className = 'random-article-card';
card.innerHTML = thumbHtml +
'<div class="random-article-info">' +
'<div class="random-article-title">' + mw.html.escape(item.title) + '</div>' +
'<div class="random-article-excerpt">' + mw.html.escape(item.excerpt) + '</div>' +
'</div>';
list.appendChild(card);
});
})
.catch(function (err) {
if (list) list.innerHTML = '<div class="random-articles-loading">Не вдалося завантажити статті.</div>';
console.error('[RA Error]', err);
});
}
mw.hook('wikipage.content').add(function () {
if (document.getElementById('random-articles-panel')) {
loadRandomArticles();
var btn = document.getElementById('random-articles-refresh');
if (btn) btn.addEventListener('click', loadRandomArticles);
}
});
/* ── 4. Vue-додаток для категорій ── */
mw.hook('wikipage.content').add(function () {
var mountEl = document.getElementById('vue-category-cards');
if (!mountEl) return;
var categoryName = mountEl.getAttribute('data-category') || '';
var filterField = mountEl.getAttribute('data-filter-field') || 'auto';
var pageLimit = parseInt(mountEl.getAttribute('data-limit') || '50', 10);
var columns = mountEl.getAttribute('data-columns') || '3';
if (!categoryName) return;
mw.loader.getScript('https://unpkg.com/vue@3/dist/vue.global.prod.js').then(function () {
var apiBase = mw.config.get('wgScriptPath') + '/api.php';
var app = Vue.createApp({
data() {
return {
items: [], loading: true, error: null,
activeFilter: 'Всі', filters: ['Всі'],
preview: null, previewX: 0, previewY: 0, previewTimer: null,
searchQuery: '', gridColumns: columns
};
},
computed: {
filteredItems() {
var vm = this;
var q = vm.searchQuery.trim().toLowerCase();
return vm.items.filter(function (item) {
var matchFilter = vm.activeFilter === 'Всі' || item.filterLabel === vm.activeFilter;
var matchSearch = !q || item.title.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q));
return matchFilter && matchSearch;
}).sort((a,b) => a.title.localeCompare(b.title, 'uk'));
},
gridStyle() { return 'grid-template-columns: repeat(' + this.gridColumns + ', 1fr);'; }
},
mounted() { this.loadItems(); },
methods: {
showPreview(item, event) {
var vm = this; clearTransition();
var rect = event.currentTarget.getBoundingClientRect();
vm.previewTimer = setTimeout(() => {
vm.previewX = (window.innerWidth - rect.right > 290) ? rect.right + window.scrollX + 10 : rect.left + window.scrollX - 280;
vm.previewY = rect.top + window.scrollY;
vm.preview = item;
}, 250);
},
hidePreview() { this.previewTimer = setTimeout(() => { this.preview = null; }, 150); },
keepPreview() { clearTimeout(this.previewTimer); },
async loadItems() {
try {
var catResp = await fetch(apiBase + '?action=query&list=categorymembers&cmtitle=Категорія:' + encodeURIComponent(categoryName) + '&cmlimit=' + pageLimit + '&cmnamespace=0&format=json');
var catData = await catResp.json();
var members = catData.query.categorymembers || [];
if (!members.length) { this.loading = false; return; }
var titles = members.map(m => m.title).join('|');
var pageResp = await fetch(apiBase + '?action=query&titles=' + encodeURIComponent(titles) + '&prop=revisions&rvprop=content&format=json');
var pageData = await pageResp.json();
var pages = Object.values(pageData.query.pages);
var filterSet = new Set();
var promises = pages.map(async (page) => {
var content = page.revisions ? page.revisions[0]['*'] : '';
var description = extractDescription(content);
// Проста логіка фільтра
var filterVal = '';
if (filterField !== 'auto') {
var re = new RegExp('\\|\\s*' + filterField + '\\s*=\\s*([^\\|\\n\\}]+)', 'i');
var m = content.match(re);
filterVal = m ? m[1].trim().split(/[\[\],|]/)[0] : '';
}
var imgName = extractImageName(content);
var imgSrc = null;
if (imgName) {
var imgResp = await fetch(apiBase + '?action=query&titles=File:' + encodeURIComponent(imgName) + '&prop=imageinfo&iiprop=url&iiurlwidth=200&format=json');
var imgD = await imgResp.json();
var ii = Object.values(imgD.query.pages)[0].imageinfo;
if (ii) imgSrc = ii[0].thumburl || ii[0].url;
}
if (filterVal) filterSet.add(filterVal);
return {
title: page.title,
filterLabel: filterVal || 'Інше',
description: description,
imgSrc: imgSrc,
pageUrl: mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(page.title.replace(/ /g, '_')))
};
});
this.items = await Promise.all(promises);
this.filters = ['Всі', ...Array.from(filterSet).sort()];
} catch (e) { this.error = 'Помилка завантаження'; }
this.loading = false;
}
},
template: `
<div class="vcc-app">
<div class="vcc-topbar">
<input class="vcc-search" type="text" v-model="searchQuery" placeholder="Пошук..." />
<span class="vcc-count" v-if="!loading">Знайдено: {{ filteredItems.length }}</span>
</div>
<div class="vcc-layout">
<div class="vcc-main">
<div v-if="loading" class="vcc-loading">Завантаження...</div>
<div v-else class="vcc-grid" :style="gridStyle">
<a v-for="item in filteredItems" :href="item.pageUrl" class="vcc-card" @mouseenter="showPreview(item, $event)" @mouseleave="hidePreview">
<div class="vcc-card-img-wrap">
<img v-if="item.imgSrc" :src="item.imgSrc" class="vcc-card-img" />
<div v-else class="vcc-card-img-placeholder">{{ item.title[0] }}</div>
</div>
<div class="vcc-card-body">
<div class="vcc-card-badge" v-if="item.filterLabel !== 'Інше'">{{ item.filterLabel }}</div>
<div class="vcc-card-name">{{ item.title }}</div>
</div>
</a>
</div>
</div>
<div class="vcc-sidebar" v-if="filters.length > 2">
<button v-for="f in filters" :class="['vcc-filter-btn', {active: activeFilter===f}]" @click="activeFilter=f">{{ f }}</button>
</div>
</div>
<teleport to="body">
<div v-if="preview" class="vcc-preview" :style="{top: previewY+'px', left: previewX+'px'}" @mouseenter="keepPreview" @mouseleave="hidePreview">
<div class="vcc-preview-name">{{ preview.title }}</div>
<div class="vcc-preview-desc">{{ preview.description }}</div>
</div>
</teleport>
</div>`
}).mount('#vue-category-cards');
});
});
/* ── 5. Анімація статистики ── */
function animateNumber(element, target) {
var current = 0;
var increment = target / 50;
var timer = setInterval(function() {
current += increment;
if (current >= target) {
element.textContent = target;
clearInterval(timer);
} else {
element.textContent = Math.floor(current);
}
}, 20);
}
mw.hook('wikipage.content').add(function() {
if (mw.config.get('wgPageName') !== 'Головна_сторінка') return;
var statsNumbers = document.querySelectorAll('.stats-panel-number');
var observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
statsNumbers.forEach(el => {
var num = parseInt(el.textContent.replace(/\D/g,''));
if (num) animateNumber(el, num);
});
observer.disconnect();
}
});
});
var panel = document.querySelector('.stats-panel');
if (panel) observer.observe(panel);
});
/* ── 6. Modern UI: Прогрес та Particles ── */
mw.hook('wikipage.content').add(function() {
if (mw.config.get('wgPageName') !== 'Головна_сторінка') return;
// Progress Bar
var prog = document.createElement('div');
prog.className = 'scroll-progress';
document.body.appendChild(prog);
window.addEventListener('scroll', function() {
var scrolled = (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100;
prog.style.width = scrolled + '%';
});
// Particles
var container = document.querySelector('.welcome-wrapper');
if (!container) return;
var canvas = document.createElement('canvas');
canvas.className = 'particles-canvas';
container.style.position = 'relative';
container.prepend(canvas);
var ctx = canvas.getContext('2d');
function resize() {
canvas.width = container.offsetWidth;
canvas.height = container.offsetHeight;
}
window.addEventListener('resize', resize);
resize();
var particles = Array.from({length: 40}, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5
}));
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(255, 215, 0, 0.4)';
particles.forEach(p => {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI*2);
ctx.fill();
});
requestAnimationFrame(draw);
}
draw();
});