[html]
<!doctype html>
<html lang="ru">
<head>
<meta charset="windows-1251" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Доска полевых заданий</title>
<link rel="stylesheet" href="https://forumstatic.ru/files/001c/82/f2/14304.css?v=3">
<script src="https://feather-tail.github.io/MyBB-scripts-bundle/js/theme-switcher/theme-iframe.js"></script>
<script src="https://feather-tail.github.io/MyBB-scripts-bundle/js/font-resizer/font-resizer-iframe.js"></script>
</head>
<body>
<section class="fo-board" aria-label="Доска полевых заданий">
<header class="fo-header">
<div>
<h1 class="fo-title">Доска полевых заданий</h1>
</div>
<div class="fo-counter">
<span>По текущим фильтрам показано заданий: <strong id="foCount">0</strong></span>
</div>
</header>
<section class="fo-controls" aria-label="Поиск и фильтры">
<div class="fo-controls-grid">
<div class="fo-field">
<div class="fo-label">Поиск</div>
<div class="fo-search">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<input class="fo-input" id="q" type="search" placeholder="Название, локация, риск, ключевое слово…" autocomplete="off" />
</div>
</div>
<div class="fo-field">
<label class="fo-label" for="difficulty">Сложность</label>
<select class="fo-select" id="difficulty">
<option value="">Любая</option>
<option value="1">I</option>
<option value="2">II</option>
<option value="3">III</option>
<option value="4">IV</option>
</select>
</div>
<div class="fo-field">
<label class="fo-label" for="type">Тип</label>
<select class="fo-select" id="type">
<option value="">Любой</option>
<option value="recon">Разведка / наблюдение</option>
<option value="escort">Сопровождение</option>
<option value="investigation">Расследование</option>
<option value="supply">Доставка / подготовка</option>
<option value="maintenance">Техподдержка / периметр</option>
<option value="collection">Сбор ингредиентов</option>
<option value="training">Тренировка (полевая)</option>
</select>
</div>
<div class="fo-field">
<label class="fo-label" for="location">Локация</label>
<select class="fo-select" id="location">
<option value="">Любая</option>
<option value="campus">Кампус</option>
<option value="salem">Салем</option>
<option value="state">Штат</option>
<option value="zone">Зона ответственности</option>
<option value="restricted">Запретная зона</option>
</select>
</div>
<div class="fo-field">
<label class="fo-label" for="group">Размер группы</label>
<select class="fo-select" id="group">
<option value="">Любой</option>
<option value="solo">Соло</option>
<option value="2-3">2–3</option>
<option value="3-4">3–4</option>
<option value="4-6">4–6</option>
</select>
</div>
<div class="fo-field">
<label class="fo-label" for="risk">Риски</label>
<select class="fo-select" id="risk">
<option value="">Любые</option>
<option value="magic">Магия</option>
<option value="blight">Скверна</option>
<option value="people">Люди / социальное</option>
<option value="medical">Медицинские</option>
<option value="weather">Погодные</option>
</select>
</div>
<div class="fo-field">
<label class="fo-label" for="sort">Сортировка</label>
<select class="fo-select" id="sort">
<option value="urgency">По срочности</option>
<option value="newest">Сначала новые</option>
<option value="difficulty">По сложности</option>
<option value="reward">По награде</option>
</select>
</div>
</div>
<span class="fo-sr-only" aria-live="polite" id="foLive"></span>
</section>
<main class="fo-grid" id="grid" aria-label="Список заданий"></main>
<div class="fo-empty" id="empty" hidden></div>
</section>
<script>
(() => {
'use strict';
const root = document.querySelector('.fo-board');
if (!root) return;
const $ = (sel, r = root) => r.querySelector(sel);
const grid = $('#grid');
const empty = $('#empty');
const countEl = $('#foCount');
const live = $('#foLive');
const q = $('#q');
const difficulty = $('#difficulty');
const type = $('#type');
const locationSel = $('#location');
const group = $('#group');
const risk = $('#risk');
const sort = $('#sort');
const DATA_URL = 'https://feathertail.ru/ks/pages/fieldops-tasks.json';
const LABELS = {
difficulty: { 1: 'I', 2: 'II', 3: 'III', 4: 'IV' },
type: {
recon: 'Разведка / наблюдение',
escort: 'Сопровождение',
investigation: 'Расследование',
supply: 'Доставка / подготовка',
maintenance: 'Техподдержка / периметр',
collection: 'Сбор ингредиентов',
training: 'Тренировка (полевая)',
},
location: {
campus: 'Кампус',
salem: 'Салем',
state: 'Штат',
zone: 'Зона ответственности',
restricted: 'Запретная зона',
},
group: {
solo: 'Соло',
'2-3': '2–3',
'3-4': '3–4',
'4-6': '4–6',
},
risk: {
magic: 'Магия',
blight: 'Скверна',
people: 'Люди, социальное',
medical: 'Медицинские',
weather: 'Погодные',
},
status: { open: 'Открыто', work: 'В работе' },
};
const ICONS = {
difficulty: 'fa-signal',
location: 'fa-location-dot',
group: 'fa-people-group',
solo: 'fa-user',
type: 'fa-tag',
risks: 'fa-triangle-exclamation',
reward: 'fa-award',
date: 'fa-clock',
urgency: 'fa-bolt',
};
const REWARD_META = {
practice: { icon: 'fa-award', text: (r) => `+${Number(r.amount || 0)} $` },
money: {
icon: 'fa-dollar-sign',
text: (r) => `${Number(r.amount || 0)}${r.currency || '$'}`,
},
perk: { icon: 'fa-id-card', text: (r) => r.label || 'Бонус' },
item: { icon: 'fa-box', text: (r) => r.label || 'Предмет' },
};
const norm = (s) => (s || '').toString().toLowerCase().trim();
const getTaskUrl = (t) => {
const u = t?.url || t?.episodeUrl || t?.topicUrl || t?.link;
return typeof u === 'string' && u.trim() ? u.trim() : '';
};
const normalizeParticipants = (p) => {
if (!p) return [];
const arr = Array.isArray(p) ? p : [p];
return arr
.map((x) => {
if (typeof x === 'string') return { name: x.trim(), url: '' };
if (x && typeof x === 'object') {
const name = (x.name || x.title || x.nick || '').toString().trim();
const url = (x.url || x.href || x.link || '').toString().trim();
if (!name && url) return { name: url, url };
return name ? { name, url } : null;
}
return null;
})
.filter(Boolean)
.filter((x) => x.name);
};
const formatDateRu = (iso) => {
const d = new Date(iso + 'T00:00:00');
if (Number.isNaN(d.getTime())) return iso;
return d
.toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
.replace(/\./g, '');
};
const urgencyLabel = (n) => {
const v = Number(n) || 1;
if (v >= 3) return 'высокая';
if (v === 2) return 'средняя';
return 'низкая';
};
const normalizeRewards = (reward) => {
if (reward == null) return [];
if (typeof reward === 'number') return [{ type: 'practice', amount: reward }];
if (Array.isArray(reward)) return reward;
if (typeof reward === 'object') return [reward];
return [{ type: 'perk', label: String(reward) }];
};
const rewardText = (reward) => {
const rewards = normalizeRewards(reward);
if (!rewards.length) return '—';
return rewards
.map((r) => {
const meta = REWARD_META[r.type] || {
icon: 'fa-award',
text: (x) => x.label || String(x.amount || 'Награда'),
};
return meta.text(r);
})
.join(' • ');
};
const rewardSortValue = (reward) => {
const rewards = normalizeRewards(reward);
let v = 0;
for (const r of rewards) {
if (r.type === 'practice') v += Number(r.amount) || 0;
if (r.type === 'money') v += (Number(r.amount) || 0) * 0.5;
}
return v;
};
const createEl = (tag, props = {}, children = []) => {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k === 'html') el.innerHTML = v;
else if (k.startsWith('data-')) el.setAttribute(k, v);
else el.setAttribute(k, v);
}
for (const ch of children) el.appendChild(ch);
return el;
};
const getClampMetrics = (el) => {
const cs = getComputedStyle(el);
const lines = Math.max(1, parseInt(cs.getPropertyValue('--fo-desc-lines'), 10) || 3);
let lh = parseFloat(cs.lineHeight);
if (!Number.isFinite(lh)) {
const fs = parseFloat(cs.fontSize) || 13;
lh = fs * 1.45;
}
const maxH = lh * lines + 0.5;
return { maxH };
};
const clampDesc = (el) => {
const full = (el.dataset.full ?? el.textContent ?? '').toString();
el.dataset.full = full;
el.textContent = full;
const { maxH } = getClampMetrics(el);
if (el.scrollHeight <= maxH) {
el.dataset.clamped = '0';
return false;
}
let lo = 0;
let hi = full.length;
let best = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
el.textContent = full.slice(0, mid) + '…';
if (el.scrollHeight <= maxH) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
let cut = full.slice(0, best);
const cutByWord = cut.replace(/\s+\S*$/, '').trimEnd();
cut = cutByWord.length >= 12 ? cutByWord : cut.trimEnd();
el.textContent = cut + '…';
el.dataset.clamped = '1';
return true;
};
const modal = (() => {
const wrap = createEl('div', {
class: 'fo-modal',
hidden: '',
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': 'foModalTitle',
});
const panel = createEl('div', { class: 'fo-modal__panel' });
const head = createEl('div', { class: 'fo-modal__head' });
const title = createEl('h3', {
class: 'fo-modal__title',
id: 'foModalTitle',
text: '',
});
const close = createEl('button', {
class: 'fo-modal__close',
type: 'button',
'aria-label': 'Закрыть',
text: 'x',
});
const content = createEl('div', { class: 'fo-modal__content' });
const media = createEl('div', { class: 'fo-modal__media' }, [
createEl('img', {
id: 'foModalImg',
alt: 'Карта задания',
loading: 'lazy',
}),
]);
const meta = createEl('div', {
class: 'fo-modal__meta',
id: 'foModalMeta',
});
const peopleWrap = createEl(
'div',
{ class: 'fo-modal__people', id: 'foModalPeopleWrap', hidden: '' },
[
createEl('div', { class: 'fo-modal__people-h', text: 'Участники:' }),
createEl('ul', { class: 'fo-modal__people-list', id: 'foModalPeople' }),
],
);
const body = createEl('div', { class: 'fo-modal__body' }, [
createEl('p', { class: 'fo-modal__desc', id: 'foModalDesc', text: '' }),
peopleWrap,
]);
head.append(title, close);
content.append(media, meta);
panel.append(head, content, body);
wrap.append(panel);
root.append(wrap);
let opened = false;
let prevOverflow = '';
const closeModal = () => {
if (!opened) return;
opened = false;
wrap.hidden = true;
wrap.setAttribute('aria-hidden', 'true');
document.documentElement.style.overflow = prevOverflow;
close.blur();
};
const row = (label, icon, value, titleText = '') => {
const v = (value ?? '—').toString();
return createEl('div', { class: 'fo-modal__row' }, [
createEl('div', { class: 'fo-modal__k' }, [
createEl('i', { class: `fa-solid ${icon}`, 'aria-hidden': 'true' }),
createEl('span', { text: label }),
]),
createEl('div', {
class: 'fo-modal__v',
text: v,
title: titleText || v,
}),
]);
};
const openModal = (t) => {
prevOverflow = document.documentElement.style.overflow || '';
document.documentElement.style.overflow = 'hidden';
const taskUrl = getTaskUrl(t);
title.innerHTML = '';
if (t.status === 'work' && taskUrl) {
title.append(
createEl('a', {
class: 'fo-titlelink',
href: taskUrl,
target: '_blank',
rel: 'noopener noreferrer',
text: t.title || 'Задание',
}),
);
} else {
title.textContent = t.title || 'Задание';
}
$('#foModalDesc', wrap).textContent = (t.summary || '').toString();
const imgEl = $('#foModalImg', wrap);
imgEl.src = t.image || '';
imgEl.alt = t.imageAlt || 'Карта задания';
const metaEl = $('#foModalMeta', wrap);
metaEl.innerHTML = '';
const diffShort = LABELS.difficulty[t.difficulty] || t.difficulty;
metaEl.append(
row('Статус', 'fa-circle', LABELS.status[t.status] || t.status),
row('Сложность', ICONS.difficulty, diffShort, `Сложность ${diffShort}`),
row('Локация', ICONS.location, LABELS.location[t.location] || t.location),
row(
'Размер группы',
t.group === 'solo' ? ICONS.solo : ICONS.group,
LABELS.group[t.group] || t.group,
),
row('Тип', ICONS.type, LABELS.type[t.type] || t.type),
row(
'Риски',
ICONS.risks,
Array.isArray(t.risks) ? t.risks.map((r) => LABELS.risk[r] || r).join(', ') : '—',
),
row('Награда', ICONS.reward, rewardText(t.reward)),
);
metaEl.append(
row('Дата', ICONS.date, formatDateRu(t.created || '')),
row('Срочность', ICONS.urgency, urgencyLabel(t.urgency), `срочность: ${urgencyLabel(t.urgency)}`),
);
const people = normalizeParticipants(t.participants || t.people || t.members || t.players);
const peopleWrapEl = $('#foModalPeopleWrap', wrap);
const peopleListEl = $('#foModalPeople', wrap);
if (peopleListEl) peopleListEl.innerHTML = '';
if (people.length && peopleWrapEl && peopleListEl) {
peopleWrapEl.hidden = false;
for (const p of people) {
const li = document.createElement('li');
if (p.url) {
li.append(
createEl('a', {
href: p.url,
target: '_blank',
rel: 'noopener noreferrer',
text: p.name,
}),
);
} else {
li.textContent = p.name;
}
peopleListEl.append(li);
}
} else if (peopleWrapEl) {
peopleWrapEl.hidden = true;
}
wrap.hidden = false;
wrap.removeAttribute('aria-hidden');
opened = true;
close.focus();
};
close.addEventListener('click', closeModal);
wrap.addEventListener('click', (e) => {
if (e.target === wrap) closeModal();
});
document.addEventListener('keydown', (e) => {
if (opened && e.key === 'Escape') closeModal();
});
return { openModal };
})();
const buildCard = (t) => {
const statusText = LABELS.status[t.status] || t.status;
const badgeClass =
t.status === 'work' ? 'fo-badge fo-badge--work' : 'fo-badge fo-badge--open';
const diffShort = LABELS.difficulty[t.difficulty] || String(t.difficulty || '');
const diffFull = `Сложность ${diffShort}`;
const locLabel = LABELS.location[t.location] || t.location;
const groupLabel = LABELS.group[t.group] || t.group;
const groupIcon = t.group === 'solo' ? 'fa-user' : 'fa-people-group';
const risksText = (Array.isArray(t.risks) ? t.risks : [])
.map((r) => LABELS.risk[r] || r)
.join(', ');
const article = createEl('article', {
class: 'fo-card',
'data-task-id': t.id || '',
});
const img = createEl('img', {
src: t.image || '',
alt: t.imageAlt || 'Карта локации',
loading: 'lazy',
});
const badge = createEl('span', { class: badgeClass }, [
createEl('i', { class: 'fa-solid fa-circle', 'aria-hidden': 'true' }),
createEl('span', { text: statusText }),
]);
const media = createEl('div', { class: 'fo-media' }, [img, badge]);
const taskUrl = getTaskUrl(t);
const title =
t.status === 'work' && taskUrl
? createEl('h2', { class: 'fo-h' }, [
createEl('a', {
class: 'fo-hlink',
href: taskUrl,
target: '_blank',
rel: 'noopener noreferrer',
text: t.title || 'Без названия',
}),
])
: createEl('h2', {
class: 'fo-h',
text: t.title || 'Без названия',
});
const top = createEl('div', { class: 'fo-top' }, [title]);
const meta = createEl('ul', { class: 'fo-meta' }, [
createEl('li', {}, [
createEl('i', { class: 'fa-solid fa-signal', 'aria-hidden': 'true' }),
createEl('span', {
class: 'fo-meta-text',
text: diffShort,
title: diffFull,
}),
]),
createEl('li', {}, [
createEl('i', { class: 'fa-solid fa-location-dot', 'aria-hidden': 'true' }),
createEl('span', {
class: 'fo-meta-text',
text: locLabel,
title: locLabel,
}),
]),
createEl('li', {}, [
createEl('i', { class: `fa-solid ${groupIcon}`, 'aria-hidden': 'true' }),
createEl('span', { class: 'fo-meta-text', text: groupLabel }),
]),
]);
const descText = (t.summary || '').toString();
const desc = createEl('p', { class: 'fo-desc', text: descText });
desc.dataset.full = descText;
const moreBtn = createEl('button', {
type: 'button',
class: 'fo-more fo-more--ghost',
text: 'Читать полностью',
});
moreBtn.addEventListener('click', () => modal.openModal(t));
const actions = createEl('div', { class: 'fo-actions' }, [moreBtn]);
const kv = createEl('div', { class: 'fo-kv' }, [
createEl('div', { class: 'fo-kvrow' }, [
createEl('div', { class: 'fo-k' }, [
createEl('i', { class: 'fa-solid fa-tag', 'aria-hidden': 'true' }),
createEl('span', { text: 'Тип' }),
]),
createEl('div', { class: 'fo-v', text: LABELS.type[t.type] || t.type }),
]),
createEl('div', { class: 'fo-kvrow' }, [
createEl('div', { class: 'fo-k' }, [
createEl('i', { class: 'fa-solid fa-triangle-exclamation', 'aria-hidden': 'true' }),
createEl('span', { text: 'Риски' }),
]),
createEl('div', { class: 'fo-v', text: risksText || '—' }),
]),
createEl('div', { class: 'fo-kvrow' }, [
createEl('div', { class: 'fo-k' }, [
createEl('i', { class: 'fa-solid fa-award', 'aria-hidden': 'true' }),
createEl('span', { text: 'Награда' }),
]),
createEl('div', { class: 'fo-v', text: rewardText(t.reward) }),
]),
]);
const foot = createEl('div', { class: 'fo-foot' }, [
createEl('span', {}, [
createEl('i', { class: 'fa-regular fa-clock', 'aria-hidden': 'true' }),
document.createTextNode(formatDateRu(t.created || '')),
]),
createEl('span', {}, [
createEl('i', { class: 'fa-solid fa-bolt', 'aria-hidden': 'true' }),
document.createTextNode(`срочность: ${urgencyLabel(t.urgency)}`),
]),
]);
const body = createEl('div', { class: 'fo-body' }, [top, meta, desc, actions, kv, foot]);
article.append(media, body);
return article;
};
const buildSearchHaystack = (t) => {
const rewardStr = rewardText(t.reward);
const peopleStr = normalizeParticipants(t.participants || t.people || t.members || t.players)
.map((p) => p.name)
.join(' ');
const parts = [
t.title,
t.summary,
LABELS.type[t.type] || t.type,
LABELS.location[t.location] || t.location,
Array.isArray(t.risks) ? t.risks.map((r) => LABELS.risk[r] || r).join(' ') : '',
LABELS.group[t.group] || t.group,
LABELS.status[t.status] || t.status,
String(t.difficulty || ''),
peopleStr,
rewardStr,
];
return norm(parts.filter(Boolean).join(' '));
};
const sortKey = (t, mode) => {
const urgency = Number(t.urgency) || 0;
const created = new Date((t.created || '1970-01-01') + 'T00:00:00').getTime() || 0;
const diff = Number(t.difficulty) || 0;
const reward = rewardSortValue(t.reward);
if (mode === 'newest') return created;
if (mode === 'difficulty') return diff;
if (mode === 'reward') return reward;
return urgency;
};
let TASKS = [];
let TASKS_BY_ID = new Map();
const shouldEnableModalButton = (t, clamped) => {
if (clamped) return true;
if (!t) return false;
if (t.status === 'work') return true;
const people = normalizeParticipants(t.participants || t.people || t.members || t.players);
return people.length > 0;
};
const applyDescClampAndButtons = () => {
const cards = grid.querySelectorAll('.fo-card');
cards.forEach((card) => {
const desc = card.querySelector('.fo-desc');
const btn = card.querySelector('.fo-more');
if (!desc || !btn) return;
const clamped = clampDesc(desc);
const id = card.getAttribute('data-task-id') || '';
const t = id ? TASKS_BY_ID.get(id) : null;
const enable = shouldEnableModalButton(t, clamped);
if (enable) {
btn.classList.remove('fo-more--ghost');
btn.disabled = false;
btn.removeAttribute('aria-hidden');
btn.tabIndex = 0;
} else {
btn.classList.add('fo-more--ghost');
btn.disabled = true;
btn.setAttribute('aria-hidden', 'true');
btn.tabIndex = -1;
}
});
};
const render = () => {
const needle = norm(q.value);
const diffVal = difficulty.value;
const typeVal = type.value;
const locVal = locationSel.value;
const groupVal = group.value;
const riskVal = risk.value;
const sortMode = sort.value;
const filtered = TASKS.filter((t) => {
if (diffVal && String(t.difficulty) !== diffVal) return false;
if (typeVal && t.type !== typeVal) return false;
if (locVal && t.location !== locVal) return false;
if (groupVal && t.group !== groupVal) return false;
const risks = Array.isArray(t.risks) ? t.risks : [];
if (riskVal && !risks.includes(riskVal)) return false;
if (needle) {
const hay = t._hay || '';
if (!hay.includes(needle)) return false;
}
return true;
});
filtered.sort((a, b) => sortKey(b, sortMode) - sortKey(a, sortMode));
grid.innerHTML = '';
const frag = document.createDocumentFragment();
for (const t of filtered) frag.appendChild(buildCard(t));
grid.appendChild(frag);
applyDescClampAndButtons();
countEl.textContent = String(filtered.length);
live.textContent = `Показано заданий: ${filtered.length}`;
if (filtered.length === 0) {
empty.hidden = false;
empty.textContent = 'Ничего не найдено. Попробуйте изменить фильтры или запрос.';
} else {
empty.hidden = true;
empty.textContent = '';
}
};
const bind = () => {
[q, difficulty, type, locationSel, group, risk, sort].forEach((el) => {
el.addEventListener('input', render);
el.addEventListener('change', render);
});
let raf = 0;
window.addEventListener(
'resize',
() => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
applyDescClampAndButtons();
});
},
{ passive: true },
);
};
const load = async () => {
empty.hidden = false;
empty.textContent = 'Загрузка заданий…';
try {
const res = await fetch(DATA_URL, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
TASKS = Array.isArray(data.tasks) ? data.tasks : [];
TASKS = TASKS.map((t) => {
const tt = { ...t };
tt._hay = buildSearchHaystack(tt);
return tt;
});
TASKS_BY_ID = new Map();
for (const t of TASKS) {
if (t && t.id) TASKS_BY_ID.set(String(t.id), t);
}
bind();
render();
} catch (e) {
grid.innerHTML = '';
countEl.textContent = '0';
empty.hidden = false;
empty.textContent = 'Не удалось загрузить JSON с заданиями. Проверьте URL или доступность файла.';
}
};
load();
})();
</script>
</body>
</html>
[/html]
[hideprofile]





















