[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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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]