MediaWiki:Common.js: відмінності між версіями
Перейти до навігації
Перейти до пошуку
Немає опису редагування Мітка: Ручний відкіт |
Немає опису редагування Мітка: Скасовано |
||
| Рядок 1: | Рядок 1: | ||
// | /* ── 1. Налаштування Viewport та базові функції ── */ | ||
(function() { | (function() { | ||
var viewport = document.querySelector('meta[name="viewport"]'); | var viewport = document.querySelector('meta[name="viewport"]'); | ||
| Рядок 10: | Рядок 10: | ||
document.head.appendChild(meta); | document.head.appendChild(meta); | ||
} | } | ||
})();$(function() { | })(); | ||
$('.category-card' | |||
/** | |||
* Глобальні допоміжні функції для парсингу контенту | |||
* (Винесено вгору, щоб були доступні всім частинам скрипта) | |||
*/ | |||
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 $card = $(this); | ||
var href = $card.attr('data-href'); | var href = $card.attr('data-href'); | ||
if (href && $(e.target).closest('a').length === 0) { | |||
if (href | window.location.href = href; | ||
} | } | ||
}); | }); | ||
}); | }); | ||
/* ── Випадкові статті на головній | |||
/* ── 3. Випадкові статті на головній ── */ | |||
function loadRandomArticles() { | function loadRandomArticles() { | ||
var list = document.getElementById('random-articles-list'); | var list = document.getElementById('random-articles-list'); | ||
| Рядок 60: | Рядок 97: | ||
.then(function (r) { return r.json(); }) | .then(function (r) { return r.json(); }) | ||
.then(function (data) { | .then(function (data) { | ||
if (!data.query || !data.query.pages) return []; | |||
var pages = Object.values(data.query.pages); | var pages = Object.values(data.query.pages); | ||
var promises = pages.map(function (page) { | var promises = pages.map(function (page) { | ||
var title = page.title; | var title = page.title; | ||
var pageUrl = mw.config.get('wgArticlePath').replace( | var pageUrl = mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(title.replace(/ /g, '_'))); | ||
var content = (page.revisions && page.revisions[0]) ? (page.revisions[0]['*'] || '') : ''; | |||
var content = (page.revisions && page.revisions[0]) | |||
var excerpt = extractDescription(content) || 'Немає опису.'; | var excerpt = extractDescription(content) || 'Немає опису.'; | ||
| Рядок 75: | Рядок 109: | ||
if (imgName) { | if (imgName) { | ||
var fileTitle = /^(Файл|File|Image|Зображення):/i.test(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') | |||
return fetch( | .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.resolve({ title: title, excerpt: excerpt, pageUrl: pageUrl, imgSrc: null }); | ||
}); | }); | ||
return Promise.all(promises); | return Promise.all(promises); | ||
}) | }) | ||
.then(function (results) { | .then(function (results) { | ||
list.innerHTML = ''; | |||
results.forEach(function (item) { | results.forEach(function (item) { | ||
var thumbHtml = item.imgSrc | var thumbHtml = item.imgSrc | ||
| Рядок 109: | Рядок 132: | ||
card.href = item.pageUrl; | card.href = item.pageUrl; | ||
card.className = 'random-article-card'; | card.className = 'random-article-card'; | ||
card.innerHTML = | card.innerHTML = thumbHtml + | ||
'<div class="random-article-info">' + | '<div class="random-article-info">' + | ||
'<div class="random-article-title">' + mw.html.escape(item.title) + '</div>' + | '<div class="random-article-title">' + mw.html.escape(item.title) + '</div>' + | ||
'<div class="random-article-excerpt">' + mw.html.escape(item.excerpt) + '</div>' + | '<div class="random-article-excerpt">' + mw.html.escape(item.excerpt) + '</div>' + | ||
'</div>'; | '</div>'; | ||
list.appendChild(card); | |||
}); | }); | ||
}) | }) | ||
.catch(function (err) { | .catch(function (err) { | ||
if (list) list.innerHTML = '<div class="random-articles-loading">Не вдалося завантажити статті.</div>'; | |||
console.error('[RA Error]', err); | |||
console.error('[RA]', err); | |||
}); | }); | ||
} | } | ||
mw.hook('wikipage.content').add(function () { | 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 () { | mw.hook('wikipage.content').add(function () { | ||
var mountEl = document.getElementById('vue-category-cards'); | var mountEl = document.getElementById('vue-category-cards'); | ||
if (!mountEl) return; | if (!mountEl) return; | ||
var categoryName = mountEl.getAttribute('data-category') || ''; | var categoryName = mountEl.getAttribute('data-category') || ''; | ||
var filterField = mountEl.getAttribute('data-filter-field') || 'auto'; | var filterField = mountEl.getAttribute('data-filter-field') || 'auto'; | ||
| Рядок 155: | Рядок 164: | ||
var columns = mountEl.getAttribute('data-columns') || '3'; | var columns = mountEl.getAttribute('data-columns') || '3'; | ||
if (!categoryName) | if (!categoryName) return; | ||
mw.loader.getScript('https://unpkg.com/vue@3/dist/vue.global.prod.js').then(function () { | mw.loader.getScript('https://unpkg.com/vue@3/dist/vue.global.prod.js').then(function () { | ||
var apiBase = mw.config.get('wgScriptPath') + '/api.php'; | var apiBase = mw.config.get('wgScriptPath') + '/api.php'; | ||
var app = Vue.createApp({ | var app = Vue.createApp({ | ||
data() { | data() { | ||
return { | return { | ||
items: | items: [], loading: true, error: null, | ||
activeFilter: 'Всі', filters: ['Всі'], | |||
preview: null, previewX: 0, previewY: 0, previewTimer: null, | |||
activeFilter: | searchQuery: '', gridColumns: columns | ||
preview: | |||
searchQuery: | |||
}; | }; | ||
}, | }, | ||
computed: { | computed: { | ||
filteredItems() { | filteredItems() { | ||
var vm = this; | var vm = this; | ||
var q = vm.searchQuery.trim().toLowerCase(); | var q = vm.searchQuery.trim().toLowerCase(); | ||
return vm.items.filter(function (item) { | return vm.items.filter(function (item) { | ||
var matchFilter = vm.activeFilter === 'Всі' || item.filterLabel === vm.activeFilter; | var matchFilter = vm.activeFilter === 'Всі' || item.filterLabel === vm.activeFilter; | ||
var matchSearch = !q || item.title.toLowerCase().includes(q) || | var matchSearch = !q || item.title.toLowerCase().includes(q) || (item.description && item.description.toLowerCase().includes(q)); | ||
return matchFilter && matchSearch; | return matchFilter && matchSearch; | ||
} | }).sort((a,b) => a.title.localeCompare(b.title, 'uk')); | ||
}, | }, | ||
gridStyle() { return 'grid-template-columns: repeat(' + this.gridColumns + ', 1fr);'; } | |||
gridStyle() { | |||
}, | }, | ||
mounted() { this.loadItems(); }, | |||
methods: { | methods: { | ||
showPreview(item, event) { | showPreview(item, event) { | ||
var vm = this; | var vm = this; clearTransition(); | ||
var rect = event.currentTarget.getBoundingClientRect(); | |||
var rect = | 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.previewTimer = setTimeout( | vm.preview = item; | ||
vm.previewY = rect.top + scrollY; | |||
vm.preview | |||
}, 250); | }, 250); | ||
}, | }, | ||
hidePreview() { | hidePreview() { this.previewTimer = setTimeout(() => { this.preview = null; }, 150); }, | ||
keepPreview() { clearTimeout(this.previewTimer); }, | |||
keepPreview() { | |||
async loadItems() { | async loadItems() { | ||
try { | try { | ||
var catResp = await fetch(apiBase + '?action=query&list=categorymembers&cmtitle=Категорія:' + encodeURIComponent(categoryName) + '&cmlimit=' + pageLimit + '&cmnamespace=0&format=json'); | |||
var catResp = await fetch( | |||
var catData = await catResp.json(); | var catData = await catResp.json(); | ||
var members = | var members = catData.query.categorymembers || []; | ||
if (!members.length) { this.loading = false; return; } | if (!members.length) { this.loading = false; return; } | ||
var titles = members.map | 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 pageResp = await fetch( | |||
var pageData = await pageResp.json(); | var pageData = await pageResp.json(); | ||
var pages | var pages = Object.values(pageData.query.pages); | ||
var filterSet = new Set(); | var filterSet = new Set(); | ||
var promises = pages.map(async (page) => { | |||
var promises = pages.map(async | var content = page.revisions ? page.revisions[0]['*'] : ''; | ||
var content = | |||
var description = extractDescription(content); | var description = extractDescription(content); | ||
var | |||
// Проста логіка фільтра | |||
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) { | 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 { | return { | ||
title: | title: page.title, | ||
filterLabel: | filterLabel: filterVal || 'Інше', | ||
description: description, | description: description, | ||
imgSrc: | imgSrc: imgSrc, | ||
pageUrl: | pageUrl: mw.config.get('wgArticlePath').replace('$1', encodeURIComponent(page.title.replace(/ /g, '_'))) | ||
}; | }; | ||
}); | }); | ||
this.items = await Promise.all(promises); | this.items = await Promise.all(promises); | ||
this.filters = ['Всі', ...Array.from(filterSet).sort()]; | |||
} catch (e) { this.error = 'Помилка завантаження'; } | |||
} catch (e) { | |||
this.loading = false; | this.loading = false; | ||
} | } | ||
}, | }, | ||
template: ` | template: ` | ||
<div class="vcc-app"> | <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'); | |||
</div> | |||
}) | |||
}); | }); | ||
}); | }); | ||
/* ── 5. Анімація статистики ── */ | |||
/* ── | |||
function animateNumber(element, target) { | function animateNumber(element, target) { | ||
var current = 0; | var current = 0; | ||
var increment = target / | var increment = target / 50; | ||
var timer = setInterval(function() { | var timer = setInterval(function() { | ||
current += increment; | current += increment; | ||
if (current >= target) { | if (current >= target) { | ||
element.textContent = target; | |||
clearInterval(timer); | clearInterval(timer); | ||
} else { | |||
element.textContent = Math.floor(current); | |||
} | } | ||
}, 20); | |||
}, | |||
} | } | ||
mw.hook('wikipage.content').add(function() { | mw.hook('wikipage.content').add(function() { | ||
if (mw.config.get('wgPageName') !== 'Головна_сторінка') return; | if (mw.config.get('wgPageName') !== 'Головна_сторінка') return; | ||
var statsNumbers = document.querySelectorAll('.stats-panel-number'); | var statsNumbers = document.querySelectorAll('.stats-panel-number'); | ||
var | 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() { | mw.hook('wikipage.content').add(function() { | ||
if (mw.config.get('wgPageName') !== 'Головна_сторінка') return; | if (mw.config.get('wgPageName') !== 'Головна_сторінка') return; | ||
// | // Progress Bar | ||
var | var prog = document.createElement('div'); | ||
prog.className = 'scroll-progress'; | |||
document.body.appendChild( | document.body.appendChild(prog); | ||
window.addEventListener('scroll', function() { | |||
var scrolled = (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100; | |||
var | prog.style.width = scrolled + '%'; | ||
}); | |||
}); | |||
/ | // Particles | ||
var container = document.querySelector('.welcome-wrapper'); | |||
if (!container) return; | |||
var | |||
if (! | |||
var canvas = document.createElement('canvas'); | var canvas = document.createElement('canvas'); | ||
canvas.className = 'particles-canvas'; | canvas.className = 'particles-canvas'; | ||
container.style.position = 'relative'; | |||
container.prepend(canvas); | |||
var ctx = canvas.getContext('2d'); | var ctx = canvas.getContext('2d'); | ||
function resize() { | |||
canvas.width = container.offsetWidth; | |||
canvas.height = container.offsetHeight; | |||
function | |||
} | } | ||
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() { | |||
} | |||
function | |||
ctx.clearRect(0, 0, canvas.width, canvas.height); | 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); | |||
} | |||
requestAnimationFrame( | |||
} | } | ||
draw(); | |||
}); | }); | ||
Версія за 11:41, 24 березня 2026
/* ── 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();
});