Гра · 7 цитат · ~3 хвилини
Цитати про Київ — від Шевченка до твіттер-мера. Розпізнаєш авторів?
Сьогоднішній челендж вже завершено — повертайся завтра або грай у тренування.
Тематичні набори
A · B · C · D — вибір · Enter — підтвердити
Натисни → або Enter
Результат
/*
script.js — гра «Хто сказав це?» (production v2)
Vanilla JS, без імпортів. Обгорнуто IIFE через лендинг-систему.
Версія 2026-05-20-v2:
+ фото авторів з Wikipedia / Wiki Commons
+ Hard mode (text input з автодоповненням, 2 бали за швидку відповідь)
+ Тематичні набори: «Хрестоматія» / «Мери» / «Сучасники» / «Все»
+ Шевченко (з «Заповіту», 1845) — додано після researcher-агента
*/
(function () {
'use strict';
const root = document.getElementById('quoteGameRoot');
if (!root) return;
const STORAGE_KEY = 'quoteGame_v1';
const SITE_URL = 'https://my-kiev.com/city/khto-skazav-tse-202858.html';
const ALLOW_STARTER_POOL = false;
// ───────────────────────────────────────────────────────────
// AUTHORS — з фото з Wiki Commons
// ───────────────────────────────────────────────────────────
const AUTHORS = {
gogol: {
name: 'Микола Гоголь',
lived: '1809–1852',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-gogol.webp',
kyiv_connection: 'Викладав історію в Київському університеті у 1830-х, мріяв перетворити Київ на «Новий Рим».',
language: 'russian'
},
klychko: {
name: 'Віталій Кличко',
lived: '1971–',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-klychko.webp',
kyiv_connection: 'Мер Києва з 2014 року. Афоризми з пресконференцій стали окремим жанром меметики.',
language: 'ukrainian'
},
chernovetsky: {
name: 'Леонід Черновецький',
lived: '1951–',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-chernovetsky.webp',
kyiv_connection: 'Мер Києва 2006–2012. «Гречки», тарифи, ексцентричні брифінги, проповіді про холодну воду.',
language: 'ukrainian'
},
omelchenko: {
name: 'Олександр Омельченко',
lived: '1938–2021',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-omelchenko.webp',
kyiv_connection: 'Мер Києва 1996–2006. Епоха «Майдану-1», реконструкції центру, Михайлівського монастиря.',
language: 'ukrainian'
},
kosakivsky: {
name: 'Іван Косаківський',
lived: '1948–2017',
kyiv_connection: 'Мер Києва 1992–1996. Перший виборний мер незалежної доби.',
language: 'ukrainian'
},
zelensky: {
name: 'Володимир Зеленський',
lived: '1978–',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-zelensky.webp',
kyiv_connection: 'Президент України з 2019. Резиденція на Банковій; військові звернення 2022+.',
language: 'ukrainian'
},
shevchenko: {
name: 'Тарас Шевченко',
lived: '1814–1861',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-shevchenko.webp',
kyiv_connection: 'Закінчив Академію мистецтв, працював у Київській археографічній комісії 1845–1847. Похований під Каневом, над Дніпром.',
language: 'ukrainian'
},
lesya: {
name: 'Леся Українка',
lived: '1871–1913',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-lesya.webp',
kyiv_connection: 'Жила в Києві з 1882 року з перервами. Музей-квартира на Саксаганського.',
language: 'ukrainian'
},
franko: {
name: 'Іван Франко',
lived: '1856–1916',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-franko.webp',
kyiv_connection: 'Двічі приїжджав у Київ (1885, 1909); листувався з київськими видавцями.',
language: 'ukrainian'
},
kuprin: {
name: 'Олександр Купрін',
lived: '1870–1938',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-kuprin.webp',
kyiv_connection: 'У 1894–1899 працював у Києві журналістом, написав «Київські типи» — портрет міста кінця XIX ст.',
language: 'russian'
},
ahmatova: {
name: 'Анна Ахматова',
lived: '1889–1966',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-ahmatova.webp',
kyiv_connection: 'Закінчила київську Фундуклеївську гімназію; вінчалася з Гумільовим у Микільській церкві.',
language: 'russian'
},
paustovsky: {
name: 'Костянтин Паустовський',
lived: '1892–1968',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-paustovsky.webp',
kyiv_connection: 'Виріс у Києві, навчався у 1-й київській гімназії. Київ описав у «Далеких роках» (1946).',
language: 'russian'
},
oleh: {
name: 'Князь Олег',
lived: '?–912',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-oleh.webp',
kyiv_connection: 'Захопив Київ 882 року, переніс сюди столицю Русі.',
language: 'old-rusian'
},
anonim: {
name: 'Народна творчість',
lived: '—',
kyiv_connection: 'Українські прислів’я та приказки про Київ, складені поколіннями.',
language: 'ukrainian'
}
};
// ───────────────────────────────────────────────────────────
// THEMES — для фільтру тематичних наборів
// ───────────────────────────────────────────────────────────
const THEMES = {
all: {
label: 'Усі цитати',
filter: function (q) { return true; }
},
classics: {
label: 'Хрестоматія',
sub: 'Класики XIX-XX ст.',
filter: function (q) { return q.category === 'classics'; }
},
politicians: {
label: 'Мери і президенти',
sub: 'Київські політики',
filter: function (q) { return q.category === 'politicians'; }
},
folklore: {
label: 'Народна творчість',
sub: 'Літопис, прислів’я',
filter: function (q) { return q.category === 'folklore' || q.category === 'history'; }
}
};
// ───────────────────────────────────────────────────────────
// QUOTES (production pool — verified, no fabrications)
// ───────────────────────────────────────────────────────────
const QUOTES = [
// ── ФОЛЬКЛОР ─────────────────────────────────────────────
{
id: 'proverb-yazyk', text: 'Язик до Києва доведе.',
author_id: 'anonim', year: null, category: 'folklore',
tags: ['proverb'],
source: { type: 'proverb', title: 'Українські прислів’я та приказки', verified_by: 'cultural-canon' },
context: 'Найвідоміше «київське» прислів’я. Прийшло з реальної практики паломництва до Києво-Печерської лаври — питати дорогу.',
difficulty: 'easy', decoys: ['shevchenko', 'klychko', 'zelensky'], crosslinks: [], flags: []
},
{
id: 'proverb-ne-za-den', text: 'Не за день Київ збудовано.',
author_id: 'anonim', year: null, category: 'folklore',
tags: ['proverb'],
source: { type: 'proverb', title: 'Українські прислів’я та приказки', verified_by: 'cultural-canon' },
context: 'Український аналог «Рим не за один день збудовано». Згадка про багатовікове формування Києва.',
difficulty: 'easy', decoys: ['shevchenko', 'klychko', 'oleh'], crosslinks: [], flags: []
},
{
id: 'proverb-buv-bachyv', text: 'Хто був у Києві, той бачив усе.',
author_id: 'anonim', year: null, category: 'folklore',
tags: ['proverb'],
source: { type: 'proverb', title: 'Українські прислів’я та приказки', verified_by: 'cultural-canon' },
context: 'Прислів’я з часів, коли Київ був головним центром паломництва і торгівлі — побачити Київ означало побачити центр світу.',
difficulty: 'medium', decoys: ['shevchenko', 'kuprin', 'oleh'], crosslinks: [], flags: []
},
// ── ЛІТОПИС ──────────────────────────────────────────────
{
id: 'oleh-mat-gradam-882', text: 'Се буди мати градом руським.',
author_id: 'oleh', year: 882, category: 'history',
tags: ['chronicle'],
source: { type: 'chronicle', title: '«Повість временних літ» (запис під 882 р.)', verified_by: 'cultural-canon' },
context: 'Слова, які літописець Нестор вкладає у вуста князя Олега після захоплення Києва й перенесення сюди столиці. Звідси походить формула «Київ — мати градам руським».',
difficulty: 'medium', decoys: ['shevchenko', 'gogol', 'anonim'], crosslinks: [], flags: ['old-rusian-language']
},
// ── ШЕВЧЕНКО (researcher 2026-05-20) ─────────────────────
{
id: 'shevchenko-zapovit-dnipro',
text: 'Як умру, то поховайте\nМене на могилі,\nСеред степу широкого,\nНа Вкраїні милій,\nЩоб лани широкополі,\nІ Дніпро, і кручі\nБуло видно, було чути,\nЯк реве ревучий.',
author_id: 'shevchenko', year: 1845, category: 'classics',
tags: ['poetry', 'dnipro'],
source: { type: 'poem', title: '«Заповіт»', url: 'https://www.t-shevchenko.name/uk/Kobzar/1845/Zapovit.html', verified_by: 'researcher-2026-05-20' },
context: 'Написано 25 грудня 1845 р. у Переяславі, коли Шевченко тяжко хворів і вважав, що помирає. Перші вісім рядків — поетичний образ України, де Дніпро з його кручами стає центральною стихією.',
difficulty: 'easy', decoys: ['franko', 'lesya', 'kulish'], crosslinks: [], flags: []
},
{
id: 'shevchenko-zapovit-krov',
text: 'Як понесе з України\nУ синєє море\nКров ворожу… отойді я\nІ лани, і гори —\nВсе покину і полину\nДо самого Бога\nМолитися…',
author_id: 'shevchenko', year: 1845, category: 'classics',
tags: ['poetry', 'dnipro'],
source: { type: 'poem', title: '«Заповіт»', url: 'https://www.t-shevchenko.name/uk/Kobzar/1845/Zapovit.html', verified_by: 'researcher-2026-05-20' },
context: 'Друга строфа «Заповіту»: від образу Дніпра, який несе з України кров у синє море, поет переходить до бунтівного звертання до Бога. Один із найвідоміших фрагментів української поезії.',
difficulty: 'medium', decoys: ['franko', 'lesya', 'kulish'], crosslinks: [], flags: []
},
// ── ГОГОЛЬ ───────────────────────────────────────────────
{
id: 'gogol-pismo-maksimovichu-1834',
text: 'Туда, туда! В Киев, в древний, в прекрасный Киев! Он наш, он не их — не правда?',
author_id: 'gogol', year: 1834, category: 'classics',
tags: ['letter', 'imperial-era'],
source: { type: 'letter', title: 'Лист до М. Максимовича', date: '1834', verified_by: 'cultural-canon' },
context: 'З листа Михайлу Максимовичу, коли Гоголь намагався перевестися професором історії до Київського університету. «Не їхній» — про Петербург.',
difficulty: 'medium', decoys: ['kuprin', 'shevchenko', 'paustovsky'], crosslinks: [], flags: ['russian-language']
},
// ── КЛИЧКО ───────────────────────────────────────────────
{
id: 'klychko-segodnya-zavtra', text: 'Сьогодні-завтра, завтра-сьогодні.',
author_id: 'klychko', year: 2014, category: 'politicians',
tags: ['mayor', 'viral'],
source: { type: 'press-conference', title: 'Пресконференція мера Києва', verified_by: 'cultural-canon' },
context: 'Канонічна цитата Кличка з перших років мерства. Стала окремим жанром «мер-коани».',
difficulty: 'easy', decoys: ['chernovetsky', 'omelchenko', 'zelensky'], crosslinks: [], flags: []
},
{
id: 'klychko-menshe-govoryty', text: 'Менше говорити — більше робити.',
author_id: 'klychko', year: 2012, category: 'politicians',
tags: ['mayor', 'slogan'],
source: { type: 'campaign-slogan', title: 'Виборча кампанія партії «УДАР»', verified_by: 'cultural-canon' },
context: 'Слоган партії «УДАР Віталія Кличка» з парламентської кампанії 2012 року.',
difficulty: 'medium', decoys: ['chernovetsky', 'omelchenko', 'kosakivsky'], crosslinks: [], flags: []
},
{
id: 'klychko-voda', text: 'Щоб холодна вода стала гарячою, її потрібно підігріти.',
author_id: 'klychko', year: 2015, category: 'politicians',
tags: ['mayor', 'viral'],
source: { type: 'press-conference', title: 'Брифінг про тарифи на гарячу воду', verified_by: 'cultural-canon' },
context: 'Класичний мерський коан Кличка, сказаний на брифінгу про комунальні тарифи. Розійшовся у соцмережах як зразок «логіки мера».',
difficulty: 'easy', decoys: ['chernovetsky', 'omelchenko', 'zelensky'], crosslinks: [], flags: []
},
{
id: 'klychko-zastupnyky',
text: 'У мене двоє заступників, четверо з яких уже місяць лежать у Кабміні.',
author_id: 'klychko', year: 2015, category: 'politicians',
tags: ['mayor', 'viral'],
source: { type: 'press-conference', title: 'Пресконференція про погодження заступників', verified_by: 'cultural-canon' },
context: 'Одна з найвідоміших арифметичних загадок Кличка. Розповідав про процес затвердження своїх заступників мера у Кабінеті Міністрів.',
difficulty: 'medium', decoys: ['chernovetsky', 'omelchenko', 'zelensky'], crosslinks: [], flags: []
},
// ── ЧЕРНОВЕЦЬКИЙ ─────────────────────────────────────────
{
id: 'chernovetsky-batko', text: 'Я для вас, як батько. А ви — мої діти.',
author_id: 'chernovetsky', year: 2008, category: 'politicians',
tags: ['mayor', 'viral'],
source: { type: 'speech', title: 'Звернення до пенсіонерів', verified_by: 'cultural-canon' },
context: 'Період «гречки» — Черновецький запровадив матеріальну допомогу пенсіонерам і виголошував патерналістські промови.',
difficulty: 'easy', decoys: ['klychko', 'omelchenko', 'kosakivsky'], crosslinks: [], flags: []
},
{
id: 'chernovetsky-kholodna-voda', text: 'Купайтеся в холодній воді — це корисно для здоров’я.',
author_id: 'chernovetsky', year: 2009, category: 'politicians',
tags: ['mayor', 'viral'],
source: { type: 'press-conference', title: 'Брифінг про відключення гарячої води', verified_by: 'cultural-canon' },
context: 'Порада киянам, які залишилися без гарячої води. Стала символом епохи Черновецького.',
difficulty: 'medium', decoys: ['klychko', 'omelchenko', 'kosakivsky'], crosslinks: [], flags: []
},
// ── ЗЕЛЕНСЬКИЙ 2022 ──────────────────────────────────────
{
id: 'zelensky-ya-tut-2022',
text: 'Я тут. Ми всі тут. Наші воїни тут. Громадяни в нашій країні тут. Ми всі тут.',
author_id: 'zelensky', year: 2022, category: 'politicians',
tags: ['president', 'war'],
source: { type: 'video', title: 'Відеозвернення з Банкової', date: '2022-02-25', verified_by: 'cultural-canon' },
context: 'Селфі-звернення президента у перші години повномасштабного вторгнення, з вулиці Банкової, разом з керівниками офісу.',
difficulty: 'easy', decoys: ['klychko', 'chernovetsky', 'omelchenko'], crosslinks: [], flags: ['military-era']
},
{
id: 'zelensky-naboiy-2022', text: 'Мені потрібні набої, а не таксі.',
author_id: 'zelensky', year: 2022, category: 'politicians',
tags: ['president', 'war'],
source: { type: 'reported-quote', title: 'Відповідь США на пропозицію евакуації', date: '2022-02-26', verified_by: 'cultural-canon' },
context: 'Президентова відповідь американцям, які пропонували евакуацію з Києва. Поширила Associated Press; цитата стала символом відмови від втечі.',
difficulty: 'easy', decoys: ['klychko', 'chernovetsky', 'omelchenko'], crosslinks: [], flags: ['military-era']
}
];
// ───────────────────────────────────────────────────────────
// RNG (seeded mulberry32)
// ───────────────────────────────────────────────────────────
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffleInPlace(arr, rng) {
rng = rng || Math.random;
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
const t = arr[i]; arr[i] = arr[j]; arr[j] = t;
}
return arr;
}
function sampleN(pool, n, rng) {
const copy = pool.slice();
shuffleInPlace(copy, rng);
return copy.slice(0, n);
}
// ───────────────────────────────────────────────────────────
// FILTERS
// ───────────────────────────────────────────────────────────
function isPlayable(q) {
if (q.flags && q.flags.indexOf('disputed') >= 0) return false;
if (q.flags && q.flags.indexOf('unverified') >= 0) return ALLOW_STARTER_POOL;
return true;
}
function pickByDifficulty(pool, diff) {
return pool.filter(function (q) { return q.difficulty === diff; });
}
function applyTheme(pool, themeKey) {
const t = THEMES[themeKey] || THEMES.all;
return pool.filter(t.filter);
}
// ───────────────────────────────────────────────────────────
// DAILY CHALLENGE / THEMED
// ───────────────────────────────────────────────────────────
function todayKey() {
const d = new Date();
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function buildChallenge(dateKey, themeKey) {
const seed = Number(dateKey.replace(/-/g, '')) + (themeKey ? themeKey.charCodeAt(0) * 1000 : 0);
const rng = mulberry32(seed);
let verified = QUOTES.filter(isPlayable);
if (themeKey && themeKey !== 'all') verified = applyTheme(verified, themeKey);
const buckets = {
easy: pickByDifficulty(verified, 'easy'),
medium: pickByDifficulty(verified, 'medium'),
hard: pickByDifficulty(verified, 'hard')
};
let pool = [];
pool = pool.concat(sampleN(buckets.easy, Math.min(2, buckets.easy.length), rng));
pool = pool.concat(sampleN(buckets.medium, Math.min(3, buckets.medium.length), rng));
pool = pool.concat(sampleN(buckets.hard, Math.min(2, buckets.hard.length), rng));
if (pool.length < 7) {
const remaining = verified.filter(function (q) { return pool.indexOf(q) < 0; });
pool = pool.concat(sampleN(remaining, Math.min(7 - pool.length, remaining.length), rng));
}
return shuffleInPlace(pool, rng).slice(0, Math.min(7, pool.length));
}
// ───────────────────────────────────────────────────────────
// OPTIONS (4 buttons)
// ───────────────────────────────────────────────────────────
function buildOptions(quote) {
const correctId = quote.author_id;
let decoyIds = [];
if (quote.decoys && quote.decoys.length) {
decoyIds = quote.decoys.filter(function (id) { return id !== correctId && AUTHORS[id]; });
}
if (decoyIds.length < 3) {
const sameCat = QUOTES
.filter(function (q) { return q.category === quote.category && q.author_id !== correctId; })
.map(function (q) { return q.author_id; });
const seen = {}; seen[correctId] = true;
decoyIds.forEach(function (id) { seen[id] = true; });
for (let i = 0; i < sameCat.length && decoyIds.length < 3; i++) {
if (!seen[sameCat[i]] && AUTHORS[sameCat[i]]) { decoyIds.push(sameCat[i]); seen[sameCat[i]] = true; }
}
const allIds = Object.keys(AUTHORS);
for (let i = 0; i < allIds.length && decoyIds.length < 3; i++) {
if (!seen[allIds[i]]) { decoyIds.push(allIds[i]); seen[allIds[i]] = true; }
}
}
decoyIds = decoyIds.slice(0, 3);
const ids = shuffleInPlace([correctId].concat(decoyIds));
return ids.map(function (id) {
return { id: id, name: AUTHORS[id].name, isCorrect: id === correctId };
});
}
// ───────────────────────────────────────────────────────────
// STATE
// ───────────────────────────────────────────────────────────
const state = {
mode: 'daily', // 'daily' | 'training' | 'hard' | 'themed'
theme: 'all',
pool: [],
currentIndex: 0,
answers: [],
chosenAuthorId: null,
chosenHardText: '',
hardHintShown: false,
currentOptions: [],
screen: 'welcome',
trainingStreak: 0,
hardPoints: 0
};
// ───────────────────────────────────────────────────────────
// STORAGE
// ───────────────────────────────────────────────────────────
function loadStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultStorage();
const parsed = JSON.parse(raw);
if (!parsed || parsed.version !== 1) return defaultStorage();
return parsed;
} catch (e) { return defaultStorage(); }
}
function defaultStorage() {
return {
version: 1,
stats: { totalQuestions: 0, totalCorrect: 0, currentStreak: 0, maxStreak: 0, lastPlayed: null, hardBest: 0 },
daily: null,
seen_quote_ids: []
};
}
function saveStorage(data) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) {} }
// ───────────────────────────────────────────────────────────
// RENDER — SCREENS
// ───────────────────────────────────────────────────────────
function show(screen) {
state.screen = screen;
root.classList.remove('is-welcome', 'is-quiz', 'is-reveal', 'is-final');
root.classList.add('is-' + screen);
root.querySelectorAll('.qg-screen').forEach(function (el) {
el.hidden = el.getAttribute('data-screen') !== screen;
});
try { window.scrollTo({ top: root.getBoundingClientRect().top + window.scrollY - 24, behavior: 'smooth' }); } catch (e) {}
}
function renderWelcomeStats() {
const data = loadStorage();
const stats = data.stats;
const dailyDoneToday = data.daily && data.daily.date === todayKey() && data.daily.completed;
const statsBlock = root.querySelector('#qgWelcomeStats');
const dailyHint = root.querySelector('#qgDailyDoneHint');
const dailyBtn = root.querySelector('#qgStartDaily');
if (stats.totalQuestions > 0) {
statsBlock.hidden = false;
const winrate = Math.round((stats.totalCorrect / stats.totalQuestions) * 100);
statsBlock.querySelector('[data-stat="streak"]').textContent = String(stats.currentStreak);
statsBlock.querySelector('[data-stat="winrate"]').textContent = winrate + '%';
statsBlock.querySelector('[data-stat="total"]').textContent = String(stats.totalQuestions);
} else {
statsBlock.hidden = true;
}
if (dailyDoneToday) {
dailyHint.hidden = false;
dailyBtn.disabled = true;
const label = dailyBtn.querySelector('.qg-btn__label');
if (label) label.textContent = 'Челендж дня завершено';
} else {
dailyHint.hidden = true;
dailyBtn.disabled = false;
const label = dailyBtn.querySelector('.qg-btn__label');
if (label) label.textContent = 'Челендж дня';
}
}
function renderQuote() {
const quote = state.pool[state.currentIndex];
if (!quote) return;
root.querySelector('#qgQuoteNum').textContent = String(state.currentIndex + 1);
const totalEl = root.querySelector('#qgQuoteTotal');
const totalWrap = root.querySelector('#qgTotalWrap');
if (state.mode === 'training') {
totalWrap.hidden = true;
} else {
totalEl.textContent = String(state.pool.length);
totalWrap.hidden = false;
}
const progressFill = root.querySelector('.qg-progress__fill');
if (progressFill) {
const pct = state.mode === 'training'
? Math.min(100, (state.trainingStreak / 10) * 100)
: (state.currentIndex / state.pool.length) * 100;
progressFill.style.width = pct + '%';
}
const quoteText = root.querySelector('.qg-quote__text');
const quoteLang = root.querySelector('.qg-quote__lang');
quoteText.textContent = quote.text;
if (quote.flags && quote.flags.indexOf('russian-language') >= 0) {
quoteLang.textContent = '(рос. оригінал)';
quoteLang.hidden = false;
} else if (quote.flags && quote.flags.indexOf('old-rusian-language') >= 0) {
quoteLang.textContent = '(давньоруський оригінал)';
quoteLang.hidden = false;
} else {
quoteLang.hidden = true;
}
// Hard mode — показуємо input, ховаємо options
const optionsEl = root.querySelector('#qgOptions');
const hardEl = root.querySelector('#qgHardInput');
if (state.mode === 'hard') {
optionsEl.hidden = true;
if (hardEl) hardEl.hidden = false;
renderHardInput(quote);
} else {
optionsEl.hidden = false;
if (hardEl) hardEl.hidden = true;
renderOptions(quote);
}
const submitBtn = root.querySelector('#qgSubmitBtn');
submitBtn.disabled = true;
const submitLabel = submitBtn.querySelector('.qg-btn__label');
if (submitLabel) submitLabel.textContent = 'Підтвердити';
state.chosenAuthorId = null;
state.chosenHardText = '';
state.hardHintShown = false;
show('quiz');
}
function renderOptions(quote) {
state.currentOptions = buildOptions(quote);
const optionsEl = root.querySelector('#qgOptions');
optionsEl.innerHTML = '';
const letters = ['A', 'B', 'C', 'D'];
state.currentOptions.forEach(function (opt, idx) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'qg-option';
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', 'false');
btn.setAttribute('data-author-id', opt.id);
btn.setAttribute('data-idx', String(idx));
btn.innerHTML = '<span class="qg-option__letter">' + letters[idx] + '</span><span class="qg-option__name"></span>';
btn.querySelector('.qg-option__name').textContent = opt.name;
btn.addEventListener('click', function () { selectOption(opt.id, btn); });
optionsEl.appendChild(btn);
});
}
function renderHardInput(quote) {
const wrap = root.querySelector('#qgHardInput');
if (!wrap) return;
wrap.innerHTML = '';
const label = document.createElement('label');
label.className = 'qg-hard__label';
label.htmlFor = 'qgHardText';
label.textContent = 'Хто це сказав? Введи прізвище автора:';
const inputWrap = document.createElement('div');
inputWrap.className = 'qg-hard__inputwrap';
const input = document.createElement('input');
input.type = 'text';
input.id = 'qgHardText';
input.className = 'qg-hard__input';
input.setAttribute('list', 'qgHardList');
input.setAttribute('autocomplete', 'off');
input.setAttribute('autocorrect', 'off');
input.setAttribute('autocapitalize', 'words');
input.setAttribute('spellcheck', 'false');
input.placeholder = 'Прізвище або «Народна творчість»';
const list = document.createElement('datalist');
list.id = 'qgHardList';
Object.keys(AUTHORS).forEach(function (id) {
const opt = document.createElement('option');
opt.value = AUTHORS[id].name;
list.appendChild(opt);
});
inputWrap.appendChild(input);
inputWrap.appendChild(list);
const hint = document.createElement('button');
hint.type = 'button';
hint.className = 'qg-hard__hint';
hint.textContent = 'Показати підказку (–1 бал)';
hint.addEventListener('click', function () {
state.hardHintShown = true;
const author = AUTHORS[quote.author_id];
hint.textContent = 'Підказка: ' + author.lived + ' — ' + (author.language === 'russian' ? 'рос.' : author.language === 'old-rusian' ? 'давньорус.' : 'укр.');
hint.disabled = true;
});
wrap.appendChild(label);
wrap.appendChild(inputWrap);
wrap.appendChild(hint);
input.addEventListener('input', function (e) {
state.chosenHardText = e.target.value;
const submitBtn = root.querySelector('#qgSubmitBtn');
submitBtn.disabled = state.chosenHardText.trim().length < 2;
});
setTimeout(function () { try { input.focus(); } catch (e) {} }, 200);
}
function selectOption(authorId, btn) {
state.chosenAuthorId = authorId;
root.querySelectorAll('.qg-option').forEach(function (el) {
el.classList.remove('is-selected');
el.setAttribute('aria-checked', 'false');
});
btn.classList.add('is-selected');
btn.setAttribute('aria-checked', 'true');
root.querySelector('#qgSubmitBtn').disabled = false;
}
function submitAnswer() {
const quote = state.pool[state.currentIndex];
let correct = false;
let points = 0;
if (state.mode === 'hard') {
if (!state.chosenHardText || state.chosenHardText.trim().length < 2) return;
const guess = state.chosenHardText.trim().toLowerCase();
const author = AUTHORS[quote.author_id];
// Точний збіг з ім'ям АБО з прізвищем (останнє слово)
const fullName = author.name.toLowerCase();
const lastName = author.name.split(' ').pop().toLowerCase();
correct = guess === fullName || guess === lastName || fullName.indexOf(guess) >= 0 && guess.length >= 4;
if (correct) {
points = state.hardHintShown ? 1 : 2;
state.hardPoints += points;
}
state.answers.push({ quoteId: quote.id, correct: correct, chosen: state.chosenHardText, points: points });
} else {
if (!state.chosenAuthorId) return;
correct = state.chosenAuthorId === quote.author_id;
state.answers.push({ quoteId: quote.id, correct: correct, chosen: state.chosenAuthorId });
root.querySelectorAll('.qg-option').forEach(function (el) {
const aid = el.getAttribute('data-author-id');
el.disabled = true;
if (aid === quote.author_id) el.classList.add('is-correct');
if (aid === state.chosenAuthorId && !correct) el.classList.add('is-wrong');
});
}
if (state.mode === 'training' && correct) state.trainingStreak += 1;
setTimeout(function () { renderReveal(correct); }, 650);
}
function renderReveal(correct) {
const quote = state.pool[state.currentIndex];
const author = AUTHORS[quote.author_id];
const verdict = root.querySelector('#qgVerdict');
verdict.classList.remove('is-correct', 'is-wrong');
verdict.classList.add(correct ? 'is-correct' : 'is-wrong');
verdict.querySelector('.qg-reveal__mark').textContent = correct ? '✓' : '✕';
let verdictWord = correct ? 'Правильно' : 'Неправильно';
if (state.mode === 'hard' && correct) {
const lastAns = state.answers[state.answers.length - 1];
verdictWord += ' (+' + (lastAns.points || 0) + ' бал' + ((lastAns.points || 0) === 1 ? '' : 'ів') + ')';
}
verdict.querySelector('.qg-reveal__word').textContent = verdictWord;
root.querySelector('#qgAuthorName').textContent = author.name;
root.querySelector('#qgAuthorYears').textContent = author.lived;
root.querySelector('#qgAuthorConnection').textContent = author.kyiv_connection;
const photoEl = root.querySelector('#qgAuthorPhoto');
photoEl.classList.remove('has-img');
photoEl.style.backgroundImage = '';
const monogram = photoEl.querySelector('.qg-author__monogram');
if (author.photo) {
photoEl.classList.add('has-img');
photoEl.style.backgroundImage = 'url("' + author.photo + '")';
photoEl.setAttribute('aria-label', author.name);
if (monogram) monogram.textContent = '';
} else if (monogram) {
const parts = author.name.split(/\s+/);
const initials = (parts[0] ? parts[0][0] : '') + (parts[1] ? parts[1][0] : '');
monogram.textContent = initials.toUpperCase();
}
root.querySelector('#qgRevealText').textContent = '“' + quote.text + '”';
const srcEl = root.querySelector('#qgRevealSrc');
const src = quote.source || {};
const parts = [];
if (src.title) parts.push(src.title);
if (quote.year) parts.push(String(quote.year));
srcEl.textContent = parts.join(', ');
const contextBody = root.querySelector('#qgContext .qg-context__body');
contextBody.textContent = quote.context || '';
const cross = root.querySelector('#qgCrosslink');
if (quote.crosslinks && quote.crosslinks.length && typeof quote.crosslinks[0] === 'object') {
const link = quote.crosslinks[0];
cross.href = link.url;
cross.querySelector('.qg-crosslink__title').textContent = link.title;
cross.hidden = false;
} else {
cross.hidden = true;
}
const nextBtn = root.querySelector('#qgNextBtn');
const isLast = state.mode !== 'training' && state.currentIndex >= state.pool.length - 1;
const nextLabel = nextBtn.querySelector('.qg-btn__label');
if (nextLabel) nextLabel.textContent = isLast ? 'Підсумок' : 'Далі';
const data = loadStorage();
if (data.seen_quote_ids.indexOf(quote.id) < 0) {
data.seen_quote_ids.push(quote.id);
if (data.seen_quote_ids.length > 60) data.seen_quote_ids.shift();
saveStorage(data);
}
show('reveal');
}
function nextQuote() {
if (state.mode === 'training') {
const last = state.answers[state.answers.length - 1];
if (!last || !last.correct) { finishRound(); return; }
state.currentIndex += 1;
pushTrainingQuote();
renderQuote();
} else {
if (state.currentIndex >= state.pool.length - 1) { finishRound(); return; }
state.currentIndex += 1;
renderQuote();
}
}
function pushTrainingQuote() {
const data = loadStorage();
const seen = data.seen_quote_ids || [];
const verified = QUOTES.filter(isPlayable);
const fresh = verified.filter(function (q) { return seen.indexOf(q.id) < 0; });
const candidates = fresh.length > 0 ? fresh : verified;
const idx = Math.floor(Math.random() * candidates.length);
state.pool.push(candidates[idx]);
}
// ───────────────────────────────────────────────────────────
// FINAL
// ───────────────────────────────────────────────────────────
function finishRound() {
const correctCount = state.answers.filter(function (a) { return a.correct; }).length;
const total = state.answers.length;
const grid = buildEmojiGrid(state.answers);
const data = loadStorage();
data.stats.totalQuestions += total;
data.stats.totalCorrect += correctCount;
if (state.mode === 'daily' || state.mode === 'themed') {
const last = data.stats.lastPlayed;
if (last && last !== todayKey()) data.stats.currentStreak = 1;
else data.stats.currentStreak = (data.stats.currentStreak || 0) + 1;
data.stats.maxStreak = Math.max(data.stats.maxStreak || 0, data.stats.currentStreak);
if (state.mode === 'daily') {
data.daily = { date: todayKey(), completed: true, score: correctCount, total: total, grid: grid };
}
} else if (state.mode === 'hard') {
data.stats.hardBest = Math.max(data.stats.hardBest || 0, state.hardPoints);
} else {
data.stats.maxStreak = Math.max(data.stats.maxStreak || 0, state.trainingStreak);
}
data.stats.lastPlayed = todayKey();
saveStorage(data);
root.querySelector('#qgFinalScore').textContent = state.mode === 'hard' ? String(state.hardPoints) : String(correctCount);
root.querySelector('#qgFinalTotal').textContent = state.mode === 'hard' ? String(total * 2) : String(total);
root.querySelector('#qgFinalGrid').textContent = grid;
const rank = computeRank(state.mode, correctCount, total);
const rankEl = root.querySelector('#qgFinalRank');
rankEl.querySelector('.qg-rank__badge').textContent = rank.badge;
rankEl.querySelector('.qg-rank__label').textContent = rank.label;
const eyebrow = root.querySelector('#qgFinalEyebrow');
let eyebrowText;
if (state.mode === 'daily') eyebrowText = 'Челендж дня · ' + todayKey();
else if (state.mode === 'hard') eyebrowText = 'Hard mode · ' + state.hardPoints + '/' + (total * 2) + ' балів';
else if (state.mode === 'themed') eyebrowText = (THEMES[state.theme] || {}).label || 'Тематичний набір';
else eyebrowText = 'Тренування';
eyebrow.textContent = eyebrowText;
const lede = root.querySelector('#qgFinalLede');
lede.textContent = rank.lede;
const linksBlock = root.querySelector('#qgFinalCrosslinks');
linksBlock.hidden = true;
const againLabel = root.querySelector('#qgPlayAgainLabel');
if (againLabel) againLabel.textContent = state.mode === 'daily' ? 'Тренування' : 'Грати ще';
show('final');
}
function computeRank(mode, score, total) {
if (mode === 'hard') {
const max = total * 2;
const ratio = max > 0 ? state.hardPoints / max : 0;
if (ratio >= 0.85) return { badge: '★', label: 'Майстер', lede: state.hardPoints + ' з ' + max + ' балів — без підказок, точно з пам’яті.' };
if (ratio >= 0.5) return { badge: '★', label: 'Знавець', lede: state.hardPoints + ' з ' + max + ' балів — упевнено.' };
return { badge: '★', label: 'Учень', lede: state.hardPoints + ' з ' + max + ' балів. Спробуй з підказками.' };
}
if (mode === 'training') {
const streak = state.trainingStreak;
if (streak >= 10) return { badge: '★', label: 'Геній', lede: 'Серія ' + streak + ' поспіль — ти знаєш цей архів напамʼять.' };
if (streak >= 5) return { badge: '★', label: 'Знавець', lede: 'Серія ' + streak + ' поспіль — впевнено.' };
if (streak >= 2) return { badge: '★', label: 'Учень', lede: 'Серія ' + streak + ' поспіль. Спробуй ще.' };
return { badge: '★', label: 'Турист', lede: 'Серія обірвалася на першому. У Києві всі починали так.' };
}
const ratio = total > 0 ? score / total : 0;
if (ratio >= 0.86) return { badge: '★', label: 'Знавець', lede: score + ' з ' + total + ' — ти явно читав архіви.' };
if (ratio >= 0.43) return { badge: '★', label: 'Учень', lede: score + ' з ' + total + ' — добре для першої спроби.' };
return { badge: '★', label: 'Турист', lede: score + ' з ' + total + ' — нічого, у Києві всі починали з нуля.' };
}
function buildEmojiGrid(answers) {
return answers.map(function (a) { return a.correct ? '✓' : '✗'; }).join('');
}
// ───────────────────────────────────────────────────────────
// SHARE
// ───────────────────────────────────────────────────────────
function shareResult() {
const data = loadStorage();
const score = state.answers.filter(function (a) { return a.correct; }).length;
const total = state.answers.length;
const grid = buildEmojiGrid(state.answers);
const date = todayKey();
const lines = [];
if (state.mode === 'daily') {
lines.push('ХТО СКАЗАВ ЦЕ? — ' + formatDateUk(date));
lines.push(score + ' / ' + total + ' ' + grid);
if (data.stats.currentStreak > 1) lines.push('Серія: ' + data.stats.currentStreak + ' дн. поспіль');
} else if (state.mode === 'hard') {
lines.push('ХТО СКАЗАВ ЦЕ? — HARD MODE');
lines.push(state.hardPoints + ' / ' + (total * 2) + ' балів ' + grid);
} else if (state.mode === 'themed') {
lines.push('ХТО СКАЗАВ ЦЕ? — ' + ((THEMES[state.theme] || {}).label || 'Тема'));
lines.push(score + ' / ' + total + ' ' + grid);
} else {
lines.push('ХТО СКАЗАВ ЦЕ? — Тренування');
lines.push('Серія: ' + state.trainingStreak + ' поспіль');
}
lines.push('');
lines.push(SITE_URL);
const text = lines.join('\n');
const toast = root.querySelector('#qgShareToast');
if (navigator.share) {
navigator.share({ title: 'Хто сказав це?', text: text, url: SITE_URL })
.catch(function () { copyFallback(text, toast); });
} else {
copyFallback(text, toast);
}
}
function copyFallback(text, toast) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
if (toast) { toast.hidden = false; setTimeout(function () { toast.hidden = true; }, 2400); }
}).catch(function () { legacyCopy(text, toast); });
} else { legacyCopy(text, toast); }
}
function legacyCopy(text, toast) {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
document.body.removeChild(ta);
if (toast) { toast.hidden = false; setTimeout(function () { toast.hidden = true; }, 2400); }
} catch (e) {}
}
function formatDateUk(iso) {
const months = ['січня','лютого','березня','квітня','травня','червня','липня','серпня','вересня','жовтня','листопада','грудня'];
const parts = iso.split('-');
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
// ───────────────────────────────────────────────────────────
// FLOW
// ───────────────────────────────────────────────────────────
function startDaily() {
const data = loadStorage();
if (data.daily && data.daily.date === todayKey() && data.daily.completed) return;
state.mode = 'daily';
state.theme = 'all';
state.pool = buildChallenge(todayKey(), 'all');
resetRound();
if (state.pool.length === 0) return;
renderQuote();
}
function startTraining() {
state.mode = 'training';
state.theme = 'all';
state.pool = [];
resetRound();
pushTrainingQuote();
renderQuote();
}
function startHard() {
state.mode = 'hard';
state.theme = 'all';
state.pool = buildChallenge(todayKey() + '-hard', 'all');
resetRound();
if (state.pool.length === 0) return;
renderQuote();
}
function startTheme(themeKey) {
state.mode = 'themed';
state.theme = themeKey;
// різний seed щоб набір варіювався по дням
state.pool = buildChallenge(todayKey() + '-' + themeKey, themeKey);
resetRound();
if (state.pool.length === 0) return;
renderQuote();
}
function resetRound() {
state.currentIndex = 0;
state.answers = [];
state.chosenAuthorId = null;
state.chosenHardText = '';
state.hardHintShown = false;
state.trainingStreak = 0;
state.hardPoints = 0;
}
function quitToWelcome() {
state.pool = [];
resetRound();
renderWelcomeStats();
show('welcome');
}
// ───────────────────────────────────────────────────────────
// BIND
// ───────────────────────────────────────────────────────────
function bind() {
root.querySelector('#qgStartDaily').addEventListener('click', startDaily);
root.querySelector('#qgStartTraining').addEventListener('click', startTraining);
const hardBtn = root.querySelector('#qgStartHard');
if (hardBtn) hardBtn.addEventListener('click', startHard);
root.querySelectorAll('[data-theme]').forEach(function (btn) {
btn.addEventListener('click', function () {
const k = btn.getAttribute('data-theme');
startTheme(k);
});
});
root.querySelector('#qgSubmitBtn').addEventListener('click', submitAnswer);
root.querySelector('#qgNextBtn').addEventListener('click', nextQuote);
root.querySelector('#qgQuitBtn').addEventListener('click', function () {
if ((state.screen === 'quiz' || state.screen === 'reveal') && state.answers.length > 0 && state.mode === 'daily') {
if (!confirm('Вийти з челенджу? Прогрес втрачено.')) return;
}
quitToWelcome();
});
root.querySelector('#qgShareBtn').addEventListener('click', shareResult);
root.querySelector('#qgPlayAgainBtn').addEventListener('click', startTraining);
document.addEventListener('keydown', function (e) {
if (!root || root.offsetParent === null) return;
if (state.screen === 'quiz') {
const k = (e.key || '').toUpperCase();
if (state.mode !== 'hard') {
if (k === 'A' || k === 'B' || k === 'C' || k === 'D') {
const idx = k.charCodeAt(0) - 65;
const btn = root.querySelector('.qg-option[data-idx="' + idx + '"]');
if (btn && !btn.disabled) { e.preventDefault(); btn.click(); }
} else if (k === 'ENTER') {
const submit = root.querySelector('#qgSubmitBtn');
if (submit && !submit.disabled) { e.preventDefault(); submit.click(); }
}
} else {
// Hard mode: Enter submits if input has content
if (k === 'ENTER') {
const submit = root.querySelector('#qgSubmitBtn');
if (submit && !submit.disabled) { e.preventDefault(); submit.click(); }
}
}
} else if (state.screen === 'reveal') {
const k = (e.key || '').toUpperCase();
if (k === 'ENTER' || e.key === 'ArrowRight') {
e.preventDefault();
root.querySelector('#qgNextBtn').click();
}
}
});
}
// ───────────────────────────────────────────────────────────
// INIT
// ───────────────────────────────────────────────────────────
function init() {
bind();
renderWelcomeStats();
show('welcome');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();