[html]
<!-- Занятые фейсклеймы — страница для форумного поста. Форум показывает [html]-пост
в iframe на чужом домене, поэтому в api.php страница не ходит: список игроков
ей присылает forms/header.html по postMessage (мост «players-list»). Форум
вставляет в iframe <base target="_blank">, потому ссылки на форум — с target="_top". -->
<article class="lore-article faceclaims-page" id="faceclaims-app">
<span class="lore-badge">Внешности</span>
<h1>Занятые внешности</h1>
<p class="roster-intro">Выберите букву или начните вводить имя внешности. Перед тем как брать внешность, проверьте, что её нет в списке.</p>
<div class="fc-toolbar">
<input type="search" class="fc-filter" id="fc-filter" placeholder="Найти фейсклейм" aria-label="Поиск по списку фейсклеймов" disabled>
<div class="fc-alphabet" id="fc-alphabet" role="group" aria-label="Фильтр по первой букве"></div>
</div>
<p class="roster-intro" id="fc-status">Загружаем список…</p>
<div class="fc-columns" id="fc-columns"><!-- сюда скрипт вставляет по одной .fc-column на пол --></div>
</article>
<script>
(() => {
// порядок колонок на странице; у мужской другой акцент, чтобы колонки не сливались
const GROUPS = [
{ gender: 'f', title: 'Женские' },
{ gender: 'm', title: 'Мужские' },
];
// имена внешностей в основном латиницей, но бывает и кириллица — сортируем
// обоими алфавитами сразу, регистр не важен
const collator = new Intl.Collator(['en', 'ru'], { sensitivity: 'base' });
const LATIN = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
const esc = (str) => String(str).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch]));
const pluralize = (n, one, few, many) => {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return few;
return many;
};
// Запрос списка игроков у страницы форума (мост в forms/header.html):
// у iframe нет ни api.php, ни токена. Без ответа за 10 секунд — ошибка.
const loadPlayers = () => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 10000);
const onMessage = (event) => {
if (event.source !== window.parent || event.data?.type !== 'players-list-response') return;
clearTimeout(timer);
window.removeEventListener('message', onMessage);
if (event.data.players) resolve(event.data.players);
else reject(new Error('no data'));
};
window.addEventListener('message', onMessage);
window.parent.postMessage({ type: 'players-list' }, '*');
});
const toEntry = (player) => ({
faceclaim: (player.character?.faceclaim || '').trim(),
gender: player.character?.gender || '',
name: player.character?.name || '',
playerName: player.name || '',
playerId: player.id,
});
const byFaceclaim = (a, b) => collator.compare(a.faceclaim, b.faceclaim);
const letterOf = (entry) => entry.faceclaim.charAt(0).toLocaleUpperCase('ru');
// [[буква, [записи]], ...] в порядке алфавита; записи уже отсортированы
const groupByLetter = (entries) => {
const letters = new Map();
entries.forEach((entry) => {
const letter = letterOf(entry);
letters.set(letter, [...(letters.get(letter) || []), entry]);
});
return [...letters];
};
// ссылка на форум — target="_top": в iframe стоит <base target="_blank">
const renderPlayer = ({ playerName, playerId }) => `<a class="fc-link" href="/profile.php?id=${esc(playerId)}" target="_top">${esc(playerName)}</a>`;
// data-search — только сама внешность в нижнем регистре: искать по персонажу
// или игроку не нужно
const renderEntry = (entry) => `
<div class="fc-entry" data-search="${esc(entry.faceclaim.toLowerCase())}" data-letter="${esc(letterOf(entry))}">
<span class="fc-entry-name">${esc(entry.faceclaim)}</span>
${entry.playerName ? `<span class="fc-entry-meta">${renderPlayer(entry)}</span>` : ''}
</div>
`;
const renderLetterGroup = ([letter, entries]) => `
<div class="fc-letter-group">
<span class="fc-initial" aria-hidden="true">${esc(letter)}</span>
<div class="fc-entries">${entries.map(renderEntry).join('')}</div>
</div>
`;
const renderColumn = ({ gender, title }, entries) => `
<section class="fc-column fc-column--${gender}">
<header class="fc-column-head">
<h2 class="fc-column-title">${title}</h2>
<span class="fc-column-count">${entries.length} ${pluralize(entries.length, 'фейсклейм', 'фейсклейма', 'фейсклеймов')}</span>
</header>
<div class="fc-column-body">${groupByLetter(entries).map(renderLetterGroup).join('')}</div>
<div class="fc-empty" hidden></div>
</section>
`;
// Все латинские буквы всегда на месте, чтобы читатель видел весь алфавит;
// без записей буква неактивна. Чужие буквы (кириллица) добавляются по факту.
const renderAlphabet = (entries) => {
const taken = new Set(entries.map(letterOf));
const letters = [...new Set([...LATIN, ...taken])].sort(collator.compare);
const button = (letter, label) => `<button type="button" class="fc-letter-btn" data-letter="${esc(letter)}" aria-pressed="false"${letter && !taken.has(letter) ? ' disabled' : ''}>${esc(label)}</button>`;
return button('', 'Все') + letters.map((letter) => button(letter, letter)).join('');
};
// Фильтр: буква и поиск работают вместе. Строки и буквенные группы без
// совпадений прячутся, а пустая колонка объясняет, почему она пуста.
const applyFilter = (root, { letter, query }) => {
const needle = query.trim().toLowerCase();
root.querySelectorAll('.fc-entry').forEach((entry) => {
entry.hidden = !entry.dataset.search.includes(needle) || (letter !== '' && entry.dataset.letter !== letter);
});
root.querySelectorAll('.fc-letter-group').forEach((group) => {
group.hidden = !group.querySelector('.fc-entry:not([hidden])');
});
root.querySelectorAll('.fc-column').forEach((column) => {
const empty = column.querySelector('.fc-entry') === null;
const none = !empty && !column.querySelector('.fc-entry:not([hidden])');
const note = column.querySelector('.fc-empty');
note.hidden = !empty && !none;
note.textContent = empty ? 'Здесь пока нет занятых внешностей.' : 'Ничего не найдено. Попробуйте другую букву или запрос.';
});
};
const setStatus = (text) => {
const status = document.getElementById('fc-status');
status.textContent = text;
status.hidden = !text;
};
// записи без пола не попадают ни в одну из двух колонок — говорим об этом,
// чтобы анкету поправили, а не искали «пропавший» фейсклейм
const unsortedNote = (entries) => {
const lost = entries.filter(({ gender }) => !GROUPS.some((group) => group.gender === gender));
return lost.length ? `Без указанного пола (в списке не показаны): ${lost.map(({ faceclaim }) => faceclaim).join(', ')}.` : '';
};
const bindFilters = (columns, alphabet, filter) => {
const state = { letter: '', query: '' };
const update = () => {
alphabet.querySelectorAll('.fc-letter-btn').forEach((btn) => btn.setAttribute('aria-pressed', String(btn.dataset.letter === state.letter)));
applyFilter(columns, state);
};
alphabet.addEventListener('click', (event) => {
const btn = event.target.closest('.fc-letter-btn');
if (!btn || btn.disabled) return;
// повторный клик по выбранной букве снимает фильтр
state.letter = btn.dataset.letter === state.letter ? '' : btn.dataset.letter;
update();
});
filter.addEventListener('input', () => {
state.query = filter.value;
update();
});
update();
};
const init = async () => {
const columns = document.getElementById('fc-columns');
const alphabet = document.getElementById('fc-alphabet');
const filter = document.getElementById('fc-filter');
try {
const entries = (await loadPlayers()).map(toEntry).filter(({ faceclaim }) => faceclaim).sort(byFaceclaim);
columns.innerHTML = GROUPS.map((group) => renderColumn(group, entries.filter(({ gender }) => gender === group.gender))).join('');
alphabet.innerHTML = renderAlphabet(entries);
setStatus(unsortedNote(entries));
filter.disabled = false;
bindFilters(columns, alphabet, filter);
} catch (err) {
setStatus('Не удалось загрузить список. Обновите страницу позже.');
}
};
init();
})();
</script>
<!-- Масштаб шрифта из настроек темы: форум показывает этот пост в iframe, и ползунок страницы до него не дотягивается — подключаем скрипт, который принимает масштаб по postMessage. -->
<script>
{
const frameFontScale = document.createElement('script');
frameFontScale.src = 'https://forumstatic.ru/files/001c/d1/ce/34725.js';
document.head.appendChild(frameFontScale);
}
</script>
[/html][hideprofile]