feat: dotykové ovládání miniher na mobilu a tabletu
CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 33s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m54s
CI / Build and push Docker image (push) Successful in 46s
CI / Notify (push) Successful in 2s

This commit is contained in:
batmanisko committed 2026-10-20 14:43:49 +02:00
1 parent a8b67fd76e
commit d2125a050b
18 files changed
+503 -63

No files matched your search

+30 -7
View File
@@ -67,15 +67,20 @@ function printHelp() {
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
}
/** Malá písmena bez diakritiky — česká klávesnice na mobilu může "kresleni" opravit na "Kreslení". */
const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
/**
* Skrytý vstup do miniher: 3× rychle kliknout na logo a pak napsat kód hry,
* případně "help" pro výpis her do konzole prohlížeče.
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
* Na dotykových zařízeních se kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
*/
export function useMinigameSecret(href: string) {
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLInputElement>(null);
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
// Otevření v novém tabu apod. necháme prohlížeči
@@ -85,6 +90,12 @@ export function useMinigameSecret(href: string) {
clicks.current++;
if (clicks.current >= SECRET_CLICKS) {
clicks.current = 0;
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
const input = inputRef.current;
if (input) {
input.value = '';
input.focus({ preventScroll: true });
}
setArmed(true);
return;
}
@@ -96,32 +107,44 @@ export function useMinigameSecret(href: string) {
useEffect(() => {
if (!armed) return;
const input = inputRef.current;
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
let typed = '';
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
typed = (typed + e.key.toLowerCase()).slice(-maxLength);
if (typed.endsWith(HELP_CODE)) {
const check = (text: string) => {
if (text.endsWith(HELP_CODE)) {
setArmed(false);
printHelp();
return;
}
const game = MINIGAMES.find(g => typed.endsWith(g.code));
const game = MINIGAMES.find(g => text.endsWith(g.code));
if (game) {
setArmed(false);
game.open();
}
};
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
typed = (typed + normalizeCode(e.key)).slice(-maxLength);
check(typed);
};
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
const onInput = () => check(normalizeCode(input?.value ?? ''));
const disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
window.addEventListener('keydown', onKey);
input?.addEventListener('input', onInput);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
input?.removeEventListener('input', onInput);
if (input) {
input.value = '';
input.blur();
}
};
}, [armed]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { armed, onLogoClick };
return { armed, onLogoClick, inputRef };
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
/** Libovolné dotykové ovládání — iPad se hlásí jako Mac, takže se nedá spoléhat na user agent. */
const QUERY = '(any-pointer: coarse)';
/** Zda má zařízení dotykový displej (podle toho minihry ukazují dotyková tlačítka a nápovědu). */
export function useTouchDevice() {
const [touch, setTouch] = useState(() => window.matchMedia(QUERY).matches);
useEffect(() => {
const media = window.matchMedia(QUERY);
const onChange = () => setTouch(media.matches);
media.addEventListener('change', onChange);
return () => media.removeEventListener('change', onChange);
}, []);
return touch;
}