Гра · 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'
},
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'
},
pidmohylny: {
name: 'Валер’ян Підмогильний',
lived: '1901–1937',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-pidmohylny.webp',
kyiv_connection: 'Київський прозаїк Розстріляного Відродження. Його роман «Місто» (1928) — головна київська книга 20-х. Розстріляний у Сандармоху 1937.',
language: 'ukrainian'
},
yanovsky: {
name: 'Юрій Яновський',
lived: '1902–1954',
photo: 'https://my-kiev.com/wp-content/uploads/2026/05/khto-skazav-tse-author-yanovsky.webp',
kyiv_connection: 'Письменник Розстріляного Відродження, у 1920-х жив у Києві, працював на ВУФКУ. Один із чільних голосів покоління «розстріляного» 20-х.',
language: 'ukrainian'
},
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: ['gogol', '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: ['gogol', '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: ['kuprin', 'gogol', '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: ['gogol', 'kuprin', 'anonim'], crosslinks: [], flags: ['old-rusian-language']
},
// ── ГОГОЛЬ ───────────────────────────────────────────────
{
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', 'paustovsky', 'ahmatova'], crosslinks: [], flags: ['russian-language']
},
// ── ПІДМОГИЛЬНИЙ ─────────────────────────────────────────
{
id: 'pidmohylny-misto-kyi-lybid',
text: '…й міста, починалась нова земля, земля первісної радості. Вода й сонце приймали всіх, хто покинув допіру пера й терези — кожного юнака, як Кия, і кожну юнку, як Либідь.',
author_id: 'pidmohylny', year: 1928, category: 'classics',
tags: ['novel', 'kyiv', 'rozstrilyane-vidrodzhennya'],
source: { type: 'novel', title: '«Місто»', verified_by: 'editor-curated' },
context: 'З роману «Місто» Валер’яна Підмогильного — головної київської книги 1920-х. Сцена дніпровського пляжу, де герої-провінціали скидають з себе канцелярське життя й «перевідкривають» себе через київський міф про Кия й Либідь.',
difficulty: 'hard', decoys: ['yanovsky', 'gogol', 'kuprin'], crosslinks: [], flags: []
},
// ── ЯНОВСЬКИЙ ────────────────────────────────────────────
{
id: 'yanovsky-hymn-mistu',
text: 'Я співаю гімн тобі, моє велике місто! Я побачив інші будинки в інших містах. Я чув інші протяги на інших, не твоїх вулицях. Я дивився в інші очі інших ліхтарів, не твоїх ліхтарів; але такого, як ти — немає.',
author_id: 'yanovsky', year: 1929, category: 'classics',
tags: ['prose', 'kyiv', 'rozstrilyane-vidrodzhennya'],
source: { type: 'prose', title: 'Київські оповідання', verified_by: 'editor-curated' },
context: 'Пряме звернення Яновського до Києва — «моє велике місто». Один з найяскравіших прикладів київської урбаністики Розстріляного Відродження: автор протиставляє Київ усім іншим містам, які він побачив, і виставляє його неперевершеним.',
difficulty: 'hard', decoys: ['pidmohylny', 'gogol', 'kuprin'], crosslinks: [], flags: []
},
// ── КУПРІН (researcher 2026-05-20) ───────────────────────
{
id: 'kuprin-bosyak-name',
text: 'В Петербурге его называют «вяземским кадетом», в Москве — «золоторотцем», в Одессе — «шарлатаном», в Харькове — «раклом». В Киеве имя ему — «босяк».',
author_id: 'kuprin', year: 1896, category: 'classics',
tags: ['essay', 'imperial-era'],
source: { type: 'sketch', title: '«Босяк» з циклу «Київські типи»', url: 'https://ru.wikisource.org/wiki/Киевские_типы_(Куприн)', verified_by: 'researcher-2026-05-20' },
context: 'Перший абзац нарису «Босяк» — Купрін відкриває портрет київського волоцюги порівнянням з міськими прізвиськами по інших імперських містах. Цикл «Київські типи» (1895–1896) — журналістські замальовки молодого Купріна для газети «Киевское слово».',
difficulty: 'easy', decoys: ['gogol', 'paustovsky', 'ahmatova'], crosslinks: [], flags: ['russian-language']
},
{
id: 'kuprin-student-dragun-kreshchatyk',
text: 'Её Мишенька, фланируя с приятелями по Крещатику и завидев издали свою мать в поношенном бурнусишке, юркнет в первый попавшийся магазин, повинуясь неодолимому чувству подлого, низменного стыда за бедно одетую мать.',
author_id: 'kuprin', year: 1895, category: 'classics',
tags: ['essay', 'kreshchatyk', 'imperial-era'],
source: { type: 'sketch', title: '«Студент-драгун» з циклу «Київські типи»', url: 'https://ru.wikisource.org/wiki/Киевские_типы_(Куприн)', verified_by: 'researcher-2026-05-20' },
context: 'З нарису про «студента-драгуна» — київського мажора 1890-х, що фланірує Хрещатиком на батьківські гроші. Купрін фіксує не лише типаж, а й сам ритуал: Хрещатик як вітрина, де провінційна мати стає соромною перешкодою.',
difficulty: 'medium', decoys: ['gogol', 'paustovsky', 'ahmatova'], crosslinks: [], flags: ['russian-language']
},
{
id: 'kuprin-khudozhnik-dnepr',
text: 'На выставку киевский художник посылает исключительно пейзажи, уморительные пейзажи, где на первом плане торчат цветы ромашки с чайное блюдечко величиною, а непосредственно за ромашкой виднеется микроскопический Днепр с неизбежным пароходом.',
author_id: 'kuprin', year: 1896, category: 'classics',
tags: ['essay', 'dnipro', 'imperial-era'],
source: { type: 'sketch', title: '«Художник» з циклу «Київські типи»', url: 'https://ru.wikisource.org/wiki/Киевские_типы_(Куприн)', verified_by: 'researcher-2026-05-20' },
context: 'Купрін уїдливо описує київський художній цех 1890-х: пейзажі з ромашкою на передньому плані і обов’язковим Дніпром з пароплавом ззаду — це візуальний штамп епохи. Натяк наприкінці нарису сучасники читали як про Мурашка і Світославського.',
difficulty: 'medium', decoys: ['gogol', 'paustovsky', 'ahmatova'], crosslinks: [], flags: ['russian-language']
},
// ── КЛИЧКО ───────────────────────────────────────────────
{
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: 'klychko-robota-ne-tam',
text: 'Головне, щоб робота була ефективною, а не там, де ти сидиш.',
author_id: 'klychko', year: 2016, category: 'politicians',
tags: ['mayor', 'viral'],
source: { type: 'press-conference', title: 'Брифінг про роботу КМДА', verified_by: 'wikiquote-2026-05-20' },
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);
// Hard rule: не повторювати в межах поточної сесії, поки є вибір
const sessionIds = state.pool.map(function (q) { return q.id; });
const notInSession = verified.filter(function (q) {
return sessionIds.indexOf(q.id) < 0;
});
var candidates;
if (notInSession.length > 0) {
// Серед нессесійних — пріоритет цитатам, не бачили глобально (LRU за seen-ordering)
const freshGlobal = notInSession.filter(function (q) { return seen.indexOf(q.id) < 0; });
candidates = freshGlobal.length > 0 ? freshGlobal : notInSession;
} else {
// Усі цитати з пулу вже у поточній сесії — fallback (буде друге коло)
candidates = 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 — modal у стилі mykyiv_4.0/single.php
// ───────────────────────────────────────────────────────────
function buildShareText() {
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);
return lines.join('n');
}
function shareResult() {
const score = state.answers.filter(function (a) { return a.correct; }).length;
const total = state.answers.length;
const grid = buildEmojiGrid(state.answers);
let resultLine, catLabel;
if (state.mode === 'hard') {
resultLine = state.hardPoints + ' / ' + (total * 2) + ' балів ' + grid;
catLabel = 'Hard mode';
} else if (state.mode === 'training') {
resultLine = 'Серія: ' + state.trainingStreak + ' поспіль';
catLabel = 'Тренування';
} else if (state.mode === 'themed') {
resultLine = score + ' / ' + total + ' ' + grid;
catLabel = (THEMES[state.theme] || {}).label || 'Тема';
} else {
resultLine = score + ' / ' + total + ' ' + grid;
catLabel = 'Челендж дня';
}
const text = buildShareText();
const enc = encodeURIComponent;
const titleEl = document.getElementById('qgSmTitle');
const catEl = document.getElementById('qgSmCat');
const dateEl = document.getElementById('qgSmDate');
const fbEl = document.getElementById('qgSmFb');
const tgEl = document.getElementById('qgSmTg');
const xEl = document.getElementById('qgSmX');
const copyEl = document.getElementById('qgSmCopy');
if (titleEl) titleEl.textContent = resultLine;
if (catEl) catEl.textContent = catLabel;
if (dateEl) dateEl.textContent = formatDateUk(todayKey());
if (fbEl) fbEl.href = 'https://www.facebook.com/sharer/sharer.php?u=' + enc(SITE_URL);
if (tgEl) tgEl.href = 'https://t.me/share/url?url=' + enc(SITE_URL) + '&text=' + enc(text);
if (xEl) xEl.href = 'https://twitter.com/intent/tweet?url=' + enc(SITE_URL) + '&text=' + enc(text);
if (copyEl) {
copyEl.setAttribute('data-share-text', text);
copyEl.classList.remove('is-done');
const lab = copyEl.querySelector('.qg-sm__copy-label');
if (lab && copyEl.dataset.origLab) lab.textContent = copyEl.dataset.origLab;
}
openShareModal();
}
function injectShareIcons() {
// wp_kses_post вирізає <svg> у _lp_html_body для non-admin REST юзерів.
// Тож SVG-маркап інжектується через JS при init.
const FB_SVG = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M22 12a10 10 0 1 0-11.6 9.9v-7H8v-3h2.4V9.5c0-2.4 1.4-3.7 3.6-3.7 1 0 2.1.2 2.1.2v2.3h-1.2c-1.2 0-1.5.7-1.5 1.5V12h2.6l-.4 3h-2.2v7A10 10 0 0 0 22 12Z"/></svg>';
const TG_SVG = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.8 3.4 2.4 11a.7.7 0 0 0 0 1.3l4.8 1.7 2 6c.2.6.9.7 1.3.3l2.6-2.4 4.7 3.5c.6.4 1.4.1 1.6-.6L22.8 4.2c.2-.6-.4-1.1-1-.8ZM10 14.5l-.4 3.8c0 .1-.2.2-.3.1l-1.7-5 9.9-6.3c.2-.1.4.1.2.2L10 14.5Z"/></svg>';
const X_SVG = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2H21l-6.52 7.45L22 22h-6.84l-4.84-6.34L4.6 22H1.84l6.98-7.97L1.6 2h6.99l4.37 5.78L18.244 2Zm-1.2 18.4h1.6L7.04 3.5H5.32L17.04 20.4Z"/></svg>';
const COPY_SVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>';
const CLOSE_SVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6 18 18 M18 6 6 18"/></svg>';
const fb = document.getElementById('qgSmFb'); if (fb && !fb.querySelector('svg')) fb.insertAdjacentHTML('afterbegin', FB_SVG);
const tg = document.getElementById('qgSmTg'); if (tg && !tg.querySelector('svg')) tg.insertAdjacentHTML('afterbegin', TG_SVG);
const x = document.getElementById('qgSmX'); if (x && !x.querySelector('svg')) x.insertAdjacentHTML('afterbegin', X_SVG);
const cp = document.getElementById('qgSmCopy'); if (cp && !cp.querySelector('svg')) cp.insertAdjacentHTML('afterbegin', COPY_SVG);
const close = document.querySelector('.qg-sm__close');
if (close && !close.querySelector('svg')) close.insertAdjacentHTML('afterbegin', CLOSE_SVG);
}
function openShareModal() {
const modal = document.getElementById('qgShareModal');
if (!modal) return;
injectShareIcons();
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('qg-share-locked');
}
function closeShareModal() {
const modal = document.getElementById('qgShareModal');
if (!modal) return;
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('qg-share-locked');
}
function copyShareText(btn) {
const text = btn.getAttribute('data-share-text') || SITE_URL;
const showDone = function () {
btn.classList.add('is-done');
const lab = btn.querySelector('.qg-sm__copy-label');
if (lab) {
if (!btn.dataset.origLab) btn.dataset.origLab = lab.textContent;
lab.textContent = '✓ Скопійовано';
}
clearTimeout(btn._copyT);
btn._copyT = setTimeout(function () {
if (lab && btn.dataset.origLab) lab.textContent = btn.dataset.origLab;
btn.classList.remove('is-done');
}, 1800);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(showDone).catch(function () { legacyCopyToClip(text, showDone); });
} else {
legacyCopyToClip(text, showDone);
}
}
function legacyCopyToClip(text, onDone) {
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 (onDone) onDone();
} 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);
// Share modal — прямі listener'и
const modal = document.getElementById('qgShareModal');
if (modal) {
modal.addEventListener('click', function (e) {
if (e.target && e.target.closest && e.target.closest('[data-qg-share-close]')) {
closeShareModal();
return;
}
const card = modal.querySelector('.qg-share-modal__card');
if (card && !card.contains(e.target)) {
closeShareModal();
}
});
const copyBtn = modal.querySelector('#qgSmCopy');
if (copyBtn) copyBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); copyShareText(copyBtn); });
}
document.addEventListener('keydown', function (e) {
// ESC закриває share modal незалежно від screen
if (e.key === 'Escape') {
const m = document.getElementById('qgShareModal');
if (m && m.classList.contains('is-open')) { e.preventDefault(); closeShareModal(); return; }
}
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();
}
})();