Initial hidden11 MVP

This commit is contained in:
alexandrev-tibco
2026-05-02 20:50:36 +02:00
commit fa04b20e30
42 changed files with 8728 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
.DS_Store
npm-debug.log
deploy
README.md
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+44
View File
@@ -0,0 +1,44 @@
name: Build and push image
on:
push:
branches:
- main
workflow_dispatch:
env:
REGISTRY: gitea.alexandre-vazquez.cloud
IMAGE_NAME: alexandrev/hidden11
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image tags
id: meta
run: |
SHORT_SHA="${GITHUB_SHA::12}"
echo "sha_tag=${REGISTRY}/${IMAGE_NAME}:${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "latest_tag=${REGISTRY}/${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ steps.meta.outputs.sha_tag }}
${{ steps.meta.outputs.latest_tag }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
+12
View File
@@ -0,0 +1,12 @@
.next
node_modules
out
dist
.env
.env*
!.env.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
tsconfig.tsbuildinfo
+29
View File
@@ -0,0 +1,29 @@
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:18-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+68
View File
@@ -0,0 +1,68 @@
# hidden11
Daily football lineup puzzle built with Next.js, React, TypeScript and Tailwind CSS.
## Local development
```bash
npm install
npm run dev
```
Open `http://localhost:3000`.
## Production build
```bash
npm run build
npm start
```
## Docker
```bash
docker build -t hidden11:latest .
docker run --rm -p 3000:3000 hidden11:latest
```
## CI/CD
Gitea Actions workflow:
- `.gitea/workflows/build-and-push.yaml`
- Builds on `main`
- Pushes:
- `gitea.alexandre-vazquez.cloud/alexandrev/hidden11:<git-sha>`
- `gitea.alexandre-vazquez.cloud/alexandrev/hidden11:latest`
Required Gitea repository secrets:
- `REGISTRY_USERNAME`: `alexandrev`
- `REGISTRY_TOKEN`: token with package/container registry permissions
Create/read tokens from `pass`; never commit credentials.
## Kubernetes
```bash
kubectl apply -f deploy/k8s
```
The app is configured for:
- Namespace: `hidden11`
- Ingress host: `hidden11.app`
- Ingress class: `public`
- TLS secret: `hidden11-tls`
## ArgoCD
```bash
kubectl apply -f deploy/argocd/application.yaml
```
The ArgoCD Application includes ArgoCD Image Updater annotations for:
- image: `gitea.alexandre-vazquez.cloud/alexandrev/hidden11`
- strategy: `digest`
- write-back: `argocd`
+31
View File
@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #061b12;
color: #f8fafc;
}
@keyframes grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
button,
input {
font: inherit;
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "hidden11",
description: "Daily football lineup puzzle",
metadataBase: new URL("https://hidden11.app"),
icons: {
icon: "/favicon.svg",
},
openGraph: {
title: "hidden11",
description: "Daily football lineup puzzle. Find the hidden five-player XI.",
url: "https://hidden11.app",
siteName: "hidden11",
images: [
{
url: "/og.svg",
width: 1200,
height: 630,
alt: "hidden11 daily football puzzle",
},
],
type: "website",
},
twitter: {
card: "summary_large_image",
title: "hidden11",
description: "Daily football lineup puzzle. Find the hidden five-player XI.",
images: ["/og.svg"],
},
};
export default function RootLayout({
children,
}: Readonly<{
children: ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+377
View File
@@ -0,0 +1,377 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import ClueList from "@/components/ClueList";
import Header from "@/components/Header";
import Pitch from "@/components/Pitch";
import PlayerSearch from "@/components/PlayerSearch";
import ShareResult from "@/components/ShareResult";
import StatsPanel from "@/components/StatsPanel";
import {
DIFFICULTIES,
MAX_ATTEMPTS,
POSITIONS,
getDailyPuzzles,
getFixedGuess,
getTodayKey,
isAttemptComplete,
isSolved,
players,
validateGuess,
} from "@/lib/game-engine";
import { SUPPORTED_LOCALES, detectLocale, getDictionary, type Locale } from "@/lib/i18n";
import { quoteForDay } from "@/lib/quotes";
import {
createInitialProgress,
createInitialStats,
loadProgress,
loadStats,
nextDifficulty,
recordDailyStats,
saveProgress,
} from "@/lib/storage";
import type { DailyProgress, Difficulty, GlobalStats, Position } from "@/lib/types";
export default function Home() {
const dateKey = useMemo(() => getTodayKey(), []);
const dailyPuzzles = useMemo(() => getDailyPuzzles(dateKey), [dateKey]);
const [locale, setLocale] = useState<Locale>("en");
const [progress, setProgress] = useState<DailyProgress>(() => createInitialProgress());
const [activeDifficulty, setActiveDifficulty] = useState<Difficulty>("easy");
const [cluesCollapsed, setCluesCollapsed] = useState(false);
const [fieldCollapsed, setFieldCollapsed] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [statsOpen, setStatsOpen] = useState(false);
const [selectedPosition, setSelectedPosition] = useState<Position>("DEF");
const [stats, setStats] = useState<GlobalStats>(() => createInitialStats());
const [draft, setDraft] = useState<Record<Position, string | null>>(() => getFixedGuess(dailyPuzzles[0]));
const [message, setMessage] = useState("");
const activePuzzle = dailyPuzzles.find((puzzle) => puzzle.difficulty === activeDifficulty) ?? dailyPuzzles[0];
const activeProgress = progress[activeDifficulty];
const isLocked = activeProgress.locked;
const isFinished = activeProgress.solved || activeProgress.attempts.length >= MAX_ATTEMPTS;
const t = useMemo(() => getDictionary(locale), [locale]);
const quote = useMemo(() => quoteForDay(dateKey, locale), [dateKey, locale]);
useEffect(() => {
const savedLocale = window.localStorage.getItem("hidden11-locale") as Locale | null;
setLocale(savedLocale ?? detectLocale(window.navigator.language));
const loaded = loadProgress(dateKey);
setProgress(loaded);
setStats(loadStats());
const firstPlayable = DIFFICULTIES.find((difficulty) => !loaded[difficulty].locked) ?? "easy";
setActiveDifficulty(firstPlayable);
}, [dateKey]);
useEffect(() => {
setDraft(getFixedGuess(activePuzzle));
const firstEmpty = POSITIONS.find((position) => position !== activePuzzle.fixedPosition) ?? "GK";
setSelectedPosition(firstEmpty);
setMessage("");
}, [activePuzzle]);
useEffect(() => {
if (isLocked || isFinished) return;
const interval = window.setInterval(() => {
setProgress((current) => {
const next = structuredClone(current);
next[activeDifficulty].elapsedSeconds += 1;
saveProgress(dateKey, next);
return next;
});
}, 1000);
return () => window.clearInterval(interval);
}, [activeDifficulty, dateKey, isFinished, isLocked]);
useEffect(() => {
if (!isFinished) return;
const nextStats = recordDailyStats(dateKey, progress);
setStats(nextStats);
setShareOpen(true);
}, [activeDifficulty, dateKey, isFinished, progress]);
function updateProgress(next: DailyProgress) {
setProgress(next);
saveProgress(dateKey, next);
}
function selectDifficulty(difficulty: Difficulty) {
if (progress[difficulty].locked) return;
setActiveDifficulty(difficulty);
}
function placePlayer(playerId: string) {
if (isLocked || isFinished || selectedPosition === activePuzzle.fixedPosition) return;
setDraft((current) => ({ ...current, [selectedPosition]: playerId }));
}
function submitGuess() {
if (isLocked || isFinished) return;
if (!isAttemptComplete(draft)) {
setMessage(t.completeLineup);
return;
}
const guess = validateGuess(activePuzzle, draft);
const solved = isSolved(guess);
const next = structuredClone(progress);
next[activeDifficulty].attempts.push(guess);
next[activeDifficulty].solved = solved;
if (solved) {
const unlocked = nextDifficulty(activeDifficulty);
if (unlocked) next[unlocked].locked = false;
setMessage(unlocked ? t.unlocked(t.difficulty[unlocked]) : t.allCompleted);
if (unlocked) {
setActiveDifficulty(unlocked);
}
} else if (next[activeDifficulty].attempts.length >= MAX_ATTEMPTS) {
setMessage(t.noAttemptsLeft);
} else {
setMessage(t.attemptsRemaining(MAX_ATTEMPTS - next[activeDifficulty].attempts.length));
}
updateProgress(next);
}
function resetDraft() {
setDraft(getFixedGuess(activePuzzle));
setSelectedPosition(POSITIONS.find((position) => position !== activePuzzle.fixedPosition) ?? "GK");
}
function revealHint() {
if (isLocked || isFinished) return;
const positionToReveal = POSITIONS.find(
(position) => position !== activePuzzle.fixedPosition && draft[position] !== activePuzzle.solution[position],
);
if (!positionToReveal) {
setMessage(t.noHintAvailable);
return;
}
setDraft((current) => ({
...current,
[positionToReveal]: activePuzzle.solution[positionToReveal],
}));
setSelectedPosition(positionToReveal);
setMessage(t.hintRevealed(positionToReveal));
}
function changeLocale(nextLocale: Locale) {
setLocale(nextLocale);
window.localStorage.setItem("hidden11-locale", nextLocale);
}
const actionButtons = (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={revealHint}
disabled={isLocked || isFinished}
className="flex h-8 w-8 items-center justify-center rounded-full border border-yellow-300/40 bg-yellow-300/15 text-sm font-black text-yellow-100 transition hover:bg-yellow-300/25 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t.hint}
title={t.hint}
>
?
</button>
<button
type="button"
onClick={submitGuess}
disabled={isLocked || isFinished}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white text-base font-black text-pitch-950 shadow-lg transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t.validate}
title={t.validate}
>
</button>
<button
type="button"
onClick={resetDraft}
disabled={isLocked || isFinished}
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/[0.06] text-sm font-black text-white transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t.reset}
title={t.reset}
>
</button>
</div>
);
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#115c39_0,#061b12_45%,#04110c_100%)]">
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-4 py-5 sm:px-6 lg:px-8">
<Header
attemptsUsed={activeProgress.attempts.length}
dateKey={dateKey}
elapsedSeconds={activeProgress.elapsedSeconds}
solved={activeProgress.solved}
t={t}
/>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={() => setStatsOpen(true)}
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 text-xs font-black uppercase text-white/65 transition hover:bg-white/10 hover:text-white"
>
{t.stats}
</button>
</div>
<div className="mt-3 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2">
<p className="truncate text-xs font-semibold text-white/65">
{quote.text[locale]} <span className="text-white/35"> {quote.author}</span>
</p>
</div>
<section
className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-white/[0.06]"
aria-label={t.puzzle}
>
<div className="flex items-stretch">
{DIFFICULTIES.map((difficulty) => (
<button
key={difficulty}
type="button"
onClick={() => selectDifficulty(difficulty)}
className={`relative flex min-h-10 flex-1 flex-col items-center justify-center px-3 py-1.5 text-center transition after:absolute after:right-[-8px] after:top-0 after:z-10 after:h-full after:w-4 after:skew-x-[-18deg] after:border-r after:border-white/10 after:content-[''] last:after:hidden ${
activeDifficulty === difficulty
? "bg-white text-pitch-950 after:bg-white"
: "text-white hover:bg-white/10 after:bg-white/[0.06]"
} ${progress[difficulty].locked ? "cursor-not-allowed opacity-45" : ""}`}
title={
progress[difficulty].locked
? `${t.difficulty[difficulty]} ${t.locked}`
: progress[difficulty].solved
? `${t.difficulty[difficulty]} ${t.solved}`
: `${t.difficulty[difficulty]} ${t.available}`
}
>
<span className="flex items-center justify-center gap-1 text-xs font-black uppercase sm:text-sm">
<span>{t.difficulty[difficulty]}</span>
{progress[difficulty].locked
? "🔒"
: progress[difficulty].solved
? "✓"
: null}
</span>
<span className="mt-0.5 block text-[10px] font-bold opacity-65 sm:text-xs">
{progress[difficulty].locked
? t.locked
: progress[difficulty].solved
? `${progress[difficulty].attempts.length}/${MAX_ATTEMPTS}`
: t.left(MAX_ATTEMPTS - progress[difficulty].attempts.length)}
</span>
</button>
))}
</div>
{message ? <p className="border-t border-white/10 px-2 py-1.5 text-xs font-semibold text-emerald-200">{message}</p> : null}
</section>
<div className="mt-4">
<PlayerSearch
disabled={isLocked || isFinished}
fixedPosition={activePuzzle.fixedPosition}
players={players}
selectedPosition={selectedPosition}
t={t}
onSelectPosition={setSelectedPosition}
onSelect={placePlayer}
/>
</div>
<div className="mt-5 grid flex-1 gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
<section className="min-w-0 lg:order-1">
<div className="mb-4 lg:hidden">
<ClueList
clues={activePuzzle.clues}
collapsed={cluesCollapsed}
onToggleCollapsed={() => setCluesCollapsed((current) => !current)}
t={t}
/>
</div>
<Pitch
actions={actionButtons}
collapsed={fieldCollapsed}
draft={draft}
fixedPosition={activePuzzle.fixedPosition}
lastGuess={activeProgress.attempts[activeProgress.attempts.length - 1]}
onToggleCollapsed={() => setFieldCollapsed((current) => !current)}
selectedPosition={selectedPosition}
t={t}
onSelectPosition={setSelectedPosition}
/>
</section>
<aside className="flex min-w-0 flex-col gap-4 lg:order-2">
<div className="hidden lg:block">
<ClueList
clues={activePuzzle.clues}
collapsed={cluesCollapsed}
onToggleCollapsed={() => setCluesCollapsed((current) => !current)}
t={t}
/>
</div>
</aside>
</div>
{isFinished && shareOpen ? (
<ShareResult
dateKey={dateKey}
locale={locale}
onClose={() => setShareOpen(false)}
progress={progress}
quote={quote}
stats={stats}
t={t}
/>
) : null}
{statsOpen ? (
<div className="fixed inset-0 z-50 flex items-end bg-black/70 p-3 backdrop-blur-sm sm:items-center sm:justify-center">
<div className="w-full rounded-lg border border-white/10 bg-pitch-950 p-4 shadow-2xl sm:max-w-lg">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-black text-white">{t.stats}</h2>
<button
type="button"
onClick={() => setStatsOpen(false)}
className="h-9 w-9 rounded-full border border-white/15 text-lg font-black text-white transition hover:bg-white/10"
aria-label={t.closeModal}
>
×
</button>
</div>
<StatsPanel stats={stats} t={t} />
</div>
</div>
) : null}
<footer className="mt-8 flex flex-wrap items-center justify-center gap-2 pb-4 text-xs text-white/45">
<span className="mr-1 font-semibold uppercase">{t.language}</span>
{SUPPORTED_LOCALES.map((item) => (
<button
key={item.code}
type="button"
onClick={() => changeLocale(item.code)}
className={`rounded px-2 py-1 font-bold transition ${
locale === item.code
? "bg-white text-pitch-950"
: "border border-white/10 text-white/60 hover:bg-white/10 hover:text-white"
}`}
>
{item.label}
</button>
))}
</footer>
</div>
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Dictionary } from "@/lib/i18n";
type ClueListProps = {
clues: string[];
collapsed: boolean;
onToggleCollapsed: () => void;
t: Dictionary;
};
export default function ClueList({ clues, collapsed, onToggleCollapsed, t }: ClueListProps) {
return (
<section className="overflow-hidden rounded-lg border border-emerald-200/30 bg-emerald-50 text-pitch-950 shadow-2xl shadow-black/20">
<button
type="button"
onClick={onToggleCollapsed}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
aria-expanded={!collapsed}
>
<span className="text-lg font-black">{t.clueTitle}</span>
<span className="flex items-center gap-2">
<span className="rounded-full bg-pitch-950 px-3 py-1 text-xs font-bold text-white">{clues.length}</span>
<span className="text-xs font-black uppercase text-pitch-950/55">{collapsed ? t.show : t.hide}</span>
</span>
</button>
{!collapsed ? (
<ul className="space-y-3 px-4 pb-4">
{clues.map((clue, index) => (
<li key={clue} className="flex gap-3 rounded-lg bg-white p-3 text-sm font-semibold text-pitch-950 shadow-sm">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-pitch-800 text-xs font-black text-white">
{index + 1}
</span>
<span className="leading-6">
{t.clue[clue] ?? clue}
</span>
</li>
))}
</ul>
) : null}
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { MAX_ATTEMPTS, getPuzzleNumber } from "@/lib/game-engine";
import { getDictionary, type Dictionary } from "@/lib/i18n";
import { formatElapsed } from "@/lib/time";
type HeaderProps = {
attemptsUsed: number;
dateKey: string;
elapsedSeconds: number;
solved: boolean;
t?: Dictionary;
};
function attemptColor(remaining: number, solved: boolean) {
if (solved) return "bg-emerald-300 text-pitch-950 shadow-emerald-300/25";
if (remaining <= 1) return "bg-red-400 text-white shadow-red-400/25";
if (remaining <= 2) return "bg-orange-400 text-pitch-950 shadow-orange-400/25";
if (remaining <= 3) return "bg-yellow-300 text-pitch-950 shadow-yellow-300/25";
return "bg-emerald-400 text-pitch-950 shadow-emerald-400/25";
}
export default function Header({ attemptsUsed, dateKey, elapsedSeconds, solved, t: dictionary }: HeaderProps) {
const remaining = Math.max(0, MAX_ATTEMPTS - attemptsUsed);
const t = dictionary ?? getDictionary("en");
return (
<header className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3">
<div className="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-2">
<p className="text-xs uppercase text-white/50">{t.puzzle}</p>
<p className="text-lg font-black">#{getPuzzleNumber(dateKey)}</p>
</div>
<div className="min-w-0 text-center sm:text-left">
<h1 className="sr-only">hidden11</h1>
<img src="/logo.svg?v=2" alt="hidden11" className="mx-auto h-12 w-auto max-w-[220px] sm:mx-0 sm:h-14" />
<p className="mt-1 text-xs text-white/60 sm:text-sm">{t.dailySubtitle}</p>
</div>
<div className="flex items-center gap-2">
<div className="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-2 text-right">
<p className="text-[10px] font-black uppercase text-white/45">{t.time}</p>
<p className="text-sm font-black text-white">{formatElapsed(elapsedSeconds)}</p>
</div>
<div
className={`flex h-16 w-16 flex-col items-center justify-center rounded-full shadow-xl transition ${attemptColor(
remaining,
solved,
)}`}
title={solved ? t.puzzleFinished : t.left(remaining)}
>
<span className="text-2xl font-black leading-none">{solved ? "✓" : remaining}</span>
<span className="mt-0.5 text-[10px] font-black uppercase leading-none opacity-70">{t.tries}</span>
</div>
</div>
</header>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { POSITIONS } from "@/lib/game-engine";
import type { Dictionary } from "@/lib/i18n";
import type { ReactNode } from "react";
import type { Feedback, Guess, Position } from "@/lib/types";
import PlayerSlot from "./PlayerSlot";
type PitchProps = {
actions: ReactNode;
collapsed: boolean;
draft: Record<Position, string | null>;
fixedPosition: Position;
lastGuess?: Guess;
onToggleCollapsed: () => void;
selectedPosition: Position;
t: Dictionary;
onSelectPosition: (position: Position) => void;
};
const layout: Record<Position, string> = {
ST: "col-start-2 row-start-1",
WING: "col-start-1 row-start-2",
MID: "col-start-3 row-start-2",
DEF: "col-start-2 row-start-3",
GK: "col-start-2 row-start-4",
};
export default function Pitch({
actions,
collapsed,
draft,
fixedPosition,
lastGuess,
onToggleCollapsed,
selectedPosition,
t,
onSelectPosition,
}: PitchProps) {
function feedbackFor(position: Position): Feedback {
if (position === fixedPosition) return "correct";
return lastGuess?.slots.find((slot) => slot.position === position)?.feedback ?? "empty";
}
return (
<div className="overflow-hidden rounded-lg border border-white/15 bg-pitch-800 shadow-2xl">
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-pitch-950/35 px-3 py-2">
<button
type="button"
onClick={onToggleCollapsed}
className="flex min-w-0 flex-1 items-center justify-between gap-3 text-left"
aria-expanded={!collapsed}
>
<span className="text-xs font-black uppercase text-white">{t.field}</span>
<span className="text-xs font-bold text-white/60">{collapsed ? t.show : t.hide}</span>
</button>
{actions}
</div>
{!collapsed ? (
<>
<div className="relative min-h-[560px] p-4">
<div className="absolute inset-4 rounded-lg border-2 border-white/70" />
<div className="absolute left-1/2 top-1/2 h-32 w-32 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white/55" />
<div className="absolute left-1/2 top-4 h-20 w-36 -translate-x-1/2 rounded-b-lg border-x-2 border-b-2 border-white/55" />
<div className="absolute bottom-4 left-1/2 h-20 w-36 -translate-x-1/2 rounded-t-lg border-x-2 border-t-2 border-white/55" />
<div className="relative z-10 grid min-h-[528px] grid-cols-3 grid-rows-4 place-items-center gap-3">
{POSITIONS.map((position) => (
<div key={position} className={layout[position]}>
<PlayerSlot
position={position}
playerId={draft[position]}
feedback={feedbackFor(position)}
fixed={position === fixedPosition}
selected={position === selectedPosition}
t={t}
onClick={() => {
if (position !== fixedPosition) onSelectPosition(position);
}}
/>
</div>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/10 bg-pitch-950/45 px-4 py-2 text-[10px] font-black uppercase text-white/70">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-sm bg-emerald-400" />
{t.exact}
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-sm bg-yellow-400" />
{t.close}
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-sm bg-slate-500" />
{t.miss}
</span>
</div>
</>
) : null}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { useMemo, useState } from "react";
import { POSITIONS } from "@/lib/game-engine";
import type { Dictionary } from "@/lib/i18n";
import type { Player, Position } from "@/lib/types";
type PlayerSearchProps = {
disabled: boolean;
fixedPosition: Position;
players: Player[];
selectedPosition: Position;
t: Dictionary;
onSelectPosition: (position: Position) => void;
onSelect: (playerId: string) => void;
};
export default function PlayerSearch({
disabled,
fixedPosition,
players,
selectedPosition,
t,
onSelectPosition,
onSelect,
}: PlayerSearchProps) {
const [query, setQuery] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const positionPlayers = useMemo(
() => players.filter((player) => player.position === selectedPosition),
[players, selectedPosition],
);
const matches = useMemo(() => {
const normalized = query.trim().toLowerCase();
const source = normalized
? positionPlayers.filter((player) => player.name.toLowerCase().includes(normalized))
: positionPlayers;
return source.slice(0, normalized ? 6 : 4);
}, [positionPlayers, query]);
const preview = query.trim() ? matches[0] : null;
function selectPosition(position: Position) {
onSelectPosition(position);
setQuery("");
setModalOpen(false);
}
function choosePlayer(playerId: string) {
onSelect(playerId);
setQuery("");
setModalOpen(false);
}
return (
<section className="rounded-lg border border-white/10 bg-white/[0.06] p-2 sm:p-3">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="hidden gap-2 overflow-x-auto pb-1 lg:flex lg:pb-0" aria-label="Choose position">
{POSITIONS.map((position) => (
<button
key={position}
type="button"
disabled={disabled || position === fixedPosition}
onClick={() => selectPosition(position)}
className={`h-11 min-w-14 rounded-lg border px-3 text-sm font-black transition ${
selectedPosition === position
? "border-white bg-white text-pitch-950"
: "border-white/15 bg-pitch-950 text-white hover:bg-white/10"
} ${position === fixedPosition ? "cursor-not-allowed opacity-45" : ""}`}
title={position === fixedPosition ? t.fixed : t.pickAndValidate(position)}
>
{position}
</button>
))}
</div>
<div className="relative min-w-0 flex-1">
<select
value={selectedPosition}
disabled={disabled}
onChange={(event) => selectPosition(event.target.value as Position)}
className="absolute left-1.5 top-1/2 z-10 h-8 -translate-y-1/2 rounded-md border border-white/10 bg-white/10 px-2 text-xs font-black text-white outline-none disabled:cursor-not-allowed disabled:opacity-40 lg:hidden"
aria-label="Choose position"
>
{POSITIONS.map((position) => (
<option key={position} value={position} disabled={position === fixedPosition}>
{position}
</option>
))}
</select>
<label htmlFor="player-search" className="sr-only">
{t.playerSearch}
</label>
<input
id="player-search"
type="search"
value={query}
disabled={disabled}
onChange={(event) => setQuery(event.target.value)}
placeholder={t.searchPlaceholder(selectedPosition)}
className="h-11 w-full rounded-lg border border-white/15 bg-pitch-950 py-0 pl-[74px] pr-12 text-sm text-white outline-none transition placeholder:text-white/35 focus:border-white disabled:cursor-not-allowed disabled:opacity-40 lg:pl-3"
/>
<button
type="button"
disabled={disabled}
onClick={() => setModalOpen(true)}
className="absolute right-1.5 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md border border-white/10 bg-white/10 text-white transition hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t.browsePlayers(selectedPosition)}
title={t.browsePlayers(selectedPosition)}
>
<span className="grid gap-0.5" aria-hidden="true">
<span className="block h-0.5 w-4 rounded bg-current" />
<span className="block h-0.5 w-4 rounded bg-current" />
<span className="block h-0.5 w-4 rounded bg-current" />
</span>
</button>
</div>
</div>
{preview ? (
<button
type="button"
disabled={disabled}
onClick={() => choosePlayer(preview.id)}
className="mt-2 flex w-full items-center justify-between gap-3 rounded-md border border-emerald-300/30 bg-emerald-300/10 px-3 py-2 text-left transition hover:bg-emerald-300/15 lg:hidden"
>
<span className="min-w-0">
<span className="block truncate text-sm font-black text-white">{preview.name}</span>
<span className="block truncate text-xs text-white/55">
{preview.club} · {preview.nationality}
</span>
</span>
<span className="shrink-0 rounded bg-emerald-300 px-2 py-1 text-[10px] font-black text-pitch-950">
{selectedPosition}
</span>
</button>
) : null}
<div className="mt-3 hidden gap-2 overflow-x-auto pb-1 lg:flex">
{matches.map((player) => (
<button
key={player.id}
type="button"
disabled={disabled}
onClick={() => choosePlayer(player.id)}
className="min-w-[210px] rounded-lg border border-white/10 bg-pitch-950/80 px-3 py-2 text-left transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<span className="flex items-center justify-between gap-3 text-sm font-semibold">
<span className="truncate">{player.name}</span>
<span className="rounded bg-white/10 px-1.5 py-0.5 text-[10px]">{player.position}</span>
</span>
<span className="mt-0.5 block text-xs text-white/55">
{player.club} · {player.nationality}
</span>
</button>
))}
</div>
{modalOpen ? (
<div
className="fixed inset-0 z-50 flex items-end bg-black/65 p-3 backdrop-blur-sm sm:items-center sm:justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="player-list-title"
>
<div className="max-h-[82vh] w-full overflow-hidden rounded-lg border border-white/10 bg-pitch-950 shadow-2xl sm:max-w-lg">
<div className="flex items-center justify-between gap-3 border-b border-white/10 p-4">
<div>
<h2 id="player-list-title" className="text-lg font-black text-white">
{t.playerListTitle(selectedPosition)}
</h2>
<p className="mt-0.5 text-xs text-white/50">{t.playersAvailable(positionPlayers.length)}</p>
</div>
<button
type="button"
onClick={() => setModalOpen(false)}
className="h-9 w-9 rounded-full border border-white/15 text-lg font-black text-white transition hover:bg-white/10"
aria-label={t.closeModal}
>
×
</button>
</div>
<div className="max-h-[62vh] overflow-y-auto p-3">
<div className="grid gap-2">
{positionPlayers.map((player) => (
<button
key={player.id}
type="button"
onClick={() => choosePlayer(player.id)}
className="rounded-lg border border-white/10 bg-white/[0.06] px-3 py-3 text-left transition hover:bg-white/10"
>
<span className="block text-sm font-bold text-white">{player.name}</span>
<span className="mt-0.5 block text-xs text-white/55">
{player.club} · {player.league} · {player.nationality}
</span>
</button>
))}
</div>
</div>
</div>
</div>
) : null}
</section>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { findPlayer } from "@/lib/game-engine";
import type { Dictionary } from "@/lib/i18n";
import type { Feedback, Position } from "@/lib/types";
type PlayerSlotProps = {
position: Position;
playerId: string | null;
feedback: Feedback;
fixed: boolean;
selected: boolean;
t: Dictionary;
onClick: () => void;
};
const feedbackClass: Record<Feedback, string> = {
correct: "border-emerald-300 bg-emerald-500 text-pitch-950",
partial: "border-yellow-200 bg-yellow-400 text-slate-950",
wrong: "border-slate-500 bg-slate-600 text-white",
empty: "border-white/55 bg-pitch-900 text-white",
};
export default function PlayerSlot({ position, playerId, feedback, fixed, selected, t, onClick }: PlayerSlotProps) {
const player = findPlayer(playerId);
return (
<button
type="button"
onClick={onClick}
className={`flex aspect-square w-[88px] flex-col items-center justify-center rounded-full border-2 p-2 text-center shadow-xl transition sm:w-[108px] ${
feedbackClass[feedback]
} ${selected ? "ring-4 ring-white" : ""}`}
>
<span className="text-xs font-black">{position}</span>
<span className="mt-1 line-clamp-2 text-[11px] font-bold leading-tight sm:text-xs">
{player?.name ?? t.select}
</span>
{fixed ? <span className="mt-1 text-[10px] font-semibold opacity-70">{t.fixed}</span> : null}
</button>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useMemo, useState } from "react";
import type { Dictionary } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n";
import { buildShareText } from "@/lib/share";
import type { DailyProgress, GlobalStats } from "@/lib/types";
import StatsPanel from "./StatsPanel";
type ShareResultProps = {
dateKey: string;
onClose: () => void;
progress: DailyProgress;
locale: Locale;
quote: { author: string; text: Record<Locale, string> };
stats: GlobalStats;
t: Dictionary;
};
export default function ShareResult({ dateKey, locale, onClose, progress, quote, stats, t }: ShareResultProps) {
const [copied, setCopied] = useState(false);
const shareText = useMemo(() => buildShareText(dateKey, progress, t), [dateKey, progress, t]);
async function copy() {
await navigator.clipboard.writeText(shareText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
}
return (
<div className="fixed inset-0 z-50 flex items-end bg-black/70 p-3 backdrop-blur-sm sm:items-center sm:justify-center">
<div
className="max-h-[88vh] w-full overflow-hidden rounded-lg border border-white/10 bg-pitch-950 shadow-2xl sm:max-w-lg"
role="dialog"
aria-modal="true"
aria-labelledby="share-title"
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 p-4">
<div>
<h2 id="share-title" className="text-lg font-black text-white">
{t.share}
</h2>
<p className="mt-0.5 text-sm text-white/55">{t.shareLead}</p>
</div>
<button
type="button"
onClick={onClose}
className="h-9 w-9 rounded-full border border-white/15 text-lg font-black text-white transition hover:bg-white/10"
aria-label={t.closeModal}
>
×
</button>
</div>
<div className="p-4">
<StatsPanel stats={stats} t={t} />
<div className="mt-4 rounded-md border border-emerald-300/20 bg-emerald-300/10 p-3">
<p className="text-sm font-semibold leading-6 text-emerald-50">
{quote.text[locale]}
</p>
<p className="mt-1 text-xs font-bold uppercase text-emerald-100/55">{quote.author}</p>
</div>
<pre className="mt-4 max-h-[38vh] overflow-auto whitespace-pre-wrap rounded-md bg-black/30 p-3 text-sm leading-6 text-white/85">
{shareText}
</pre>
<button
type="button"
onClick={copy}
className="mt-4 h-11 w-full rounded-md bg-white px-3 text-sm font-black text-pitch-950 transition hover:bg-emerald-100"
>
{copied ? t.copied : t.copy}
</button>
</div>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { Dictionary } from "@/lib/i18n";
import { formatElapsed } from "@/lib/time";
import type { GlobalStats } from "@/lib/types";
type StatsPanelProps = {
stats: GlobalStats;
t: Dictionary;
};
export default function StatsPanel({ stats, t }: StatsPanelProps) {
const maxDistribution = Math.max(...Object.values(stats.winDistribution), 1);
return (
<section className="rounded-lg border border-white/10 bg-white/[0.06] p-4">
<div>
<h3 className="text-sm font-black uppercase text-white/55">{t.stats}</h3>
<p className="mt-1 text-sm text-emerald-100">{t.statsLead}</p>
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
<Stat label={t.streak} value={stats.currentStreak.toString()} />
<Stat label={t.maxStreak} value={stats.maxStreak.toString()} />
<Stat label={t.played} value={stats.gamesPlayed.toString()} />
<Stat label={t.perfectDays} value={stats.perfectDays.toString()} />
<Stat label={t.averageTime} value={formatElapsed(stats.averageSeconds)} />
<Stat label={t.bestTime} value={stats.bestSeconds === null ? "0:00" : formatElapsed(stats.bestSeconds)} />
</div>
<div className="mt-4">
<p className="text-xs font-black uppercase text-white/55">{t.distribution}</p>
<div className="mt-2 space-y-1.5">
{Object.entries(stats.winDistribution).map(([bucket, value]) => (
<div key={bucket} className="grid grid-cols-[24px_1fr_28px] items-center gap-2 text-xs text-white/65">
<span className="font-black">{bucket === "7" ? "X" : bucket}</span>
<span className="h-2 overflow-hidden rounded-full bg-white/10">
<span
className="block h-full origin-left animate-[grow_700ms_ease-out] rounded-full bg-emerald-400"
style={{ width: `${Math.max(8, (value / maxDistribution) * 100)}%` }}
/>
</span>
<span className="text-right font-bold">{value}</span>
</div>
))}
</div>
</div>
</section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg bg-black/25 p-3 text-center">
<p className="text-2xl font-black text-white">{value}</p>
<p className="mt-1 text-[10px] font-bold uppercase text-white/45">{label}</p>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
[
{ "id": "alisson", "name": "Alisson Becker", "position": "GK", "nationality": "Brazil", "club": "Liverpool", "league": "Premier League" },
{ "id": "courtois", "name": "Thibaut Courtois", "position": "GK", "nationality": "Belgium", "club": "Real Madrid", "league": "LaLiga" },
{ "id": "donnarumma", "name": "Gianluigi Donnarumma", "position": "GK", "nationality": "Italy", "club": "Manchester City", "league": "Premier League" },
{ "id": "van-dijk", "name": "Virgil van Dijk", "position": "DEF", "nationality": "Netherlands", "club": "Liverpool", "league": "Premier League" },
{ "id": "dias", "name": "Ruben Dias", "position": "DEF", "nationality": "Portugal", "club": "Manchester City", "league": "Premier League" },
{ "id": "rudiger", "name": "Antonio Rudiger", "position": "DEF", "nationality": "Germany", "club": "Real Madrid", "league": "LaLiga" },
{ "id": "saliba", "name": "William Saliba", "position": "DEF", "nationality": "France", "club": "Arsenal", "league": "Premier League" },
{ "id": "rodri", "name": "Rodri", "position": "MID", "nationality": "Spain", "club": "Manchester City", "league": "Premier League" },
{ "id": "bellingham", "name": "Jude Bellingham", "position": "MID", "nationality": "England", "club": "Real Madrid", "league": "LaLiga" },
{ "id": "modric", "name": "Luka Modric", "position": "MID", "nationality": "Croatia", "club": "AC Milan", "league": "Serie A" },
{ "id": "odegaard", "name": "Martin Odegaard", "position": "MID", "nationality": "Norway", "club": "Arsenal", "league": "Premier League" },
{ "id": "de-bruyne", "name": "Kevin De Bruyne", "position": "MID", "nationality": "Belgium", "club": "Napoli", "league": "Serie A" },
{ "id": "saka", "name": "Bukayo Saka", "position": "WING", "nationality": "England", "club": "Arsenal", "league": "Premier League" },
{ "id": "vinicius", "name": "Vinicius Junior", "position": "WING", "nationality": "Brazil", "club": "Real Madrid", "league": "LaLiga" },
{ "id": "salah", "name": "Mohamed Salah", "position": "WING", "nationality": "Egypt", "club": "Liverpool", "league": "Premier League" },
{ "id": "mbappe", "name": "Kylian Mbappe", "position": "WING", "nationality": "France", "club": "Real Madrid", "league": "LaLiga" },
{ "id": "haaland", "name": "Erling Haaland", "position": "ST", "nationality": "Norway", "club": "Manchester City", "league": "Premier League" },
{ "id": "kane", "name": "Harry Kane", "position": "ST", "nationality": "England", "club": "Bayern Munich", "league": "Bundesliga" },
{ "id": "lewandowski", "name": "Robert Lewandowski", "position": "ST", "nationality": "Poland", "club": "Barcelona", "league": "LaLiga" },
{ "id": "lautaro", "name": "Lautaro Martinez", "position": "ST", "nationality": "Argentina", "club": "Inter", "league": "Serie A" }
]
+60
View File
@@ -0,0 +1,60 @@
[
{
"id": "2026-05-02-easy",
"date": "2026-05-02",
"difficulty": "easy",
"solution": {
"GK": "alisson",
"DEF": "van-dijk",
"MID": "bellingham",
"WING": "saka",
"ST": "haaland"
},
"fixedPosition": "GK",
"clues": [
"Two players share the same club.",
"Four players play in England.",
"The striker is Norwegian.",
"The winger is English."
]
},
{
"id": "2026-05-02-medium",
"date": "2026-05-02",
"difficulty": "medium",
"solution": {
"GK": "courtois",
"DEF": "rudiger",
"MID": "modric",
"WING": "vinicius",
"ST": "lewandowski"
},
"fixedPosition": "MID",
"clues": [
"Three players are from Real Madrid.",
"The goalkeeper is Belgian.",
"The winger is Brazilian.",
"The striker plays in Spain but not for Real Madrid."
]
},
{
"id": "2026-05-02-hard",
"date": "2026-05-02",
"difficulty": "hard",
"solution": {
"GK": "donnarumma",
"DEF": "saliba",
"MID": "de-bruyne",
"WING": "mbappe",
"ST": "kane"
},
"fixedPosition": "WING",
"clues": [
"The revealed player plays in Spain.",
"Two players are current Premier League players.",
"The defender is French.",
"The striker plays in Germany.",
"The midfielder plays for Napoli."
]
}
]
+25
View File
@@ -0,0 +1,25 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: hidden11
namespace: argocd
annotations:
argocd-image-updater.argoproj.io/image-list: hidden11=gitea.alexandre-vazquez.cloud/alexandrev/hidden11
argocd-image-updater.argoproj.io/hidden11.update-strategy: digest
argocd-image-updater.argoproj.io/hidden11.force-update: "true"
argocd-image-updater.argoproj.io/write-back-method: argocd
spec:
project: default
source:
repoURL: https://gitea.alexandre-vazquez.cloud/alexandrev/hidden11.git
targetRevision: main
path: deploy/k8s
destination:
server: https://kubernetes.default.svc
namespace: hidden11
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+43
View File
@@ -0,0 +1,43 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: hidden11
namespace: hidden11
labels:
app.kubernetes.io/name: hidden11
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: hidden11
template:
metadata:
labels:
app.kubernetes.io/name: hidden11
spec:
containers:
- name: hidden11
image: gitea.alexandre-vazquez.cloud/alexandrev/hidden11:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
+24
View File
@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hidden11
namespace: hidden11
labels:
app.kubernetes.io/name: hidden11
spec:
ingressClassName: public
tls:
- hosts:
- hidden11.app
secretName: hidden11-tls
rules:
- host: hidden11.app
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hidden11
port:
number: 80
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: hidden11
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: hidden11
namespace: hidden11
labels:
app.kubernetes.io/name: hidden11
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: hidden11
ports:
- name: http
port: 80
targetPort: 3000
+81
View File
@@ -0,0 +1,81 @@
import playersData from "@/data/players.json";
import puzzlesData from "@/data/puzzles.json";
import type { Difficulty, Feedback, Guess, Player, Position, Puzzle } from "./types";
export const POSITIONS: Position[] = ["GK", "DEF", "MID", "WING", "ST"];
export const DIFFICULTIES: Difficulty[] = ["easy", "medium", "hard"];
export const MAX_ATTEMPTS = 6;
export const players = playersData as Player[];
export const puzzles = puzzlesData as Puzzle[];
export function getTodayKey(date = new Date()): string {
return date.toISOString().slice(0, 10);
}
export function getPuzzleNumber(dateKey: string): number {
const epoch = Date.UTC(2026, 0, 1);
const current = Date.parse(`${dateKey}T00:00:00.000Z`);
return Math.max(1, Math.floor((current - epoch) / 86400000) + 1);
}
export function getDailyPuzzles(dateKey: string): Puzzle[] {
const dated = puzzles.filter((puzzle) => puzzle.date === dateKey);
const fallback = puzzles.filter((puzzle) => puzzle.date === "2026-05-02");
const source = dated.length === 3 ? dated : fallback;
return DIFFICULTIES.map((difficulty) => {
const puzzle = source.find((item) => item.difficulty === difficulty);
if (!puzzle) {
throw new Error(`Missing ${difficulty} puzzle`);
}
return puzzle;
});
}
export function findPlayer(id: string | null): Player | undefined {
if (!id) return undefined;
return players.find((player) => player.id === id);
}
export function validateGuess(puzzle: Puzzle, guess: Record<Position, string | null>): Guess {
return {
slots: POSITIONS.map((position) => ({
position,
playerId: guess[position],
feedback: scoreSlot(puzzle.solution[position], guess[position]),
})),
};
}
export function isSolved(guess: Guess): boolean {
return guess.slots.every((slot) => slot.feedback === "correct");
}
export function isAttemptComplete(guess: Record<Position, string | null>): boolean {
return POSITIONS.every((position) => Boolean(guess[position]));
}
export function getFixedGuess(puzzle: Puzzle): Record<Position, string | null> {
return POSITIONS.reduce<Record<Position, string | null>>((acc, position) => {
acc[position] = position === puzzle.fixedPosition ? puzzle.solution[position] : null;
return acc;
}, {} as Record<Position, string | null>);
}
function scoreSlot(solutionId: string, guessId: string | null): Feedback {
if (!guessId) return "empty";
if (guessId === solutionId) return "correct";
const solution = findPlayer(solutionId);
const guess = findPlayer(guessId);
if (!solution || !guess) return "wrong";
const hasClubRelation = solution.club === guess.club;
const hasLeagueMatch = solution.league === guess.league;
const hasNationalityMatch = solution.nationality === guess.nationality;
const hasPositionMatch = solution.position === guess.position;
return hasClubRelation || hasLeagueMatch || hasNationalityMatch || hasPositionMatch ? "partial" : "wrong";
}
+434
View File
@@ -0,0 +1,434 @@
import type { Difficulty } from "./types";
export type Locale = "en" | "es" | "fr" | "de" | "it" | "pt";
export const SUPPORTED_LOCALES: { code: Locale; label: string }[] = [
{ code: "en", label: "EN" },
{ code: "es", label: "ES" },
{ code: "fr", label: "FR" },
{ code: "de", label: "DE" },
{ code: "it", label: "IT" },
{ code: "pt", label: "PT" },
];
export type Dictionary = {
allCompleted: string;
available: string;
browsePlayers: (position: string) => string;
close: string;
closeModal: string;
copied: string;
copy: string;
dailySubtitle: string;
difficulty: Record<Difficulty, string>;
exact: string;
fixed: string;
field: string;
hint: string;
hide: string;
locked: string;
miss: string;
noAttemptsLeft: string;
pickAndValidate: (position: string) => string;
playerListTitle: (position: string) => string;
playerSearch: string;
playersAvailable: (count: number) => string;
puzzle: string;
puzzleFinished: string;
searchPlaceholder: (position: string) => string;
select: string;
share: string;
shareLead: string;
stats: string;
statsLead: string;
streak: string;
maxStreak: string;
played: string;
perfectDays: string;
averageTime: string;
bestTime: string;
distribution: string;
solvePrevious: string;
solved: string;
time: string;
tries: string;
unlocked: (difficulty: string) => string;
validate: string;
reset: string;
reveal: string;
show: string;
left: (count: number) => string;
completeLineup: string;
attemptsRemaining: (count: number) => string;
clueTitle: string;
language: string;
noHintAvailable: string;
hintRevealed: (position: string) => string;
clue: Record<string, string>;
};
const dictionaries: Record<Locale, Dictionary> = {
en: {
allCompleted: "All puzzles completed.",
available: "available",
browsePlayers: (position) => `Browse all ${position} players`,
close: "Close",
closeModal: "Close player list",
copied: "Copied",
copy: "Copy",
dailySubtitle: "Daily five-player football puzzle",
difficulty: { easy: "Easy", medium: "Medium", hard: "Hard" },
exact: "Exact",
fixed: "fixed",
field: "Field",
hint: "Hint",
hide: "Hide",
locked: "locked",
miss: "Miss",
noAttemptsLeft: "No attempts left.",
pickAndValidate: (position) => `Pick a ${position} and validate the lineup.`,
playerListTitle: (position) => `${position} players`,
playerSearch: "Player search",
playersAvailable: (count) => `${count} available`,
puzzle: "Puzzle",
puzzleFinished: "Puzzle finished.",
searchPlaceholder: (position) => `Search player for ${position}`,
select: "Select",
share: "Share",
shareLead: "Daily result ready",
stats: "Stats",
statsLead: "Come back tomorrow to keep the streak alive.",
streak: "Streak",
maxStreak: "Best streak",
played: "Played",
perfectDays: "Perfect days",
averageTime: "Avg time",
bestTime: "Best time",
distribution: "Distribution",
solvePrevious: "Solve the previous puzzle to unlock this lineup.",
solved: "solved",
time: "Time",
tries: "tries",
unlocked: (difficulty) => `${difficulty} unlocked.`,
validate: "Validate",
reset: "Reset",
reveal: "Reveal",
show: "Show",
left: (count) => `${count} left`,
completeLineup: "Complete the lineup before validating.",
attemptsRemaining: (count) => `${count} attempts remaining.`,
clueTitle: "Clues",
language: "Language",
noHintAvailable: "No hints available.",
hintRevealed: (position) => `${position} revealed.`,
clue: {},
},
es: {
allCompleted: "Todos los puzzles completados.",
available: "disponible",
browsePlayers: (position) => `Ver todos los jugadores ${position}`,
close: "Cerca",
closeModal: "Cerrar lista de jugadores",
copied: "Copiado",
copy: "Copiar",
dailySubtitle: "Puzzle diario de fútbol de cinco jugadores",
difficulty: { easy: "Fácil", medium: "Medio", hard: "Difícil" },
exact: "Exacto",
fixed: "fijo",
field: "Campo",
hint: "Pista",
hide: "Ocultar",
locked: "bloqueado",
miss: "Fallo",
noAttemptsLeft: "No quedan intentos.",
pickAndValidate: (position) => `Elige un ${position} y valida la alineación.`,
playerListTitle: (position) => `Jugadores ${position}`,
playerSearch: "Buscar jugador",
playersAvailable: (count) => `${count} disponibles`,
puzzle: "Puzzle",
puzzleFinished: "Puzzle terminado.",
searchPlaceholder: (position) => `Buscar jugador para ${position}`,
select: "Elegir",
share: "Compartir",
shareLead: "Resultado diario listo",
stats: "Stats",
statsLead: "Vuelve mañana para mantener la racha.",
streak: "Racha",
maxStreak: "Mejor racha",
played: "Jugados",
perfectDays: "Días perfectos",
averageTime: "Tiempo medio",
bestTime: "Mejor tiempo",
distribution: "Distribución",
solvePrevious: "Resuelve el puzzle anterior para desbloquear esta alineación.",
solved: "resuelto",
time: "Tiempo",
tries: "intentos",
unlocked: (difficulty) => `${difficulty} desbloqueado.`,
validate: "Validar",
reset: "Reset",
reveal: "Revelar",
show: "Mostrar",
left: (count) => `${count} restantes`,
completeLineup: "Completa la alineación antes de validar.",
attemptsRemaining: (count) => `Quedan ${count} intentos.`,
clueTitle: "Pistas",
language: "Idioma",
noHintAvailable: "No quedan pistas disponibles.",
hintRevealed: (position) => `${position} revelado.`,
clue: {
"Two players share the same club.": "Dos jugadores comparten club.",
"Four players play in England.": "Cuatro jugadores juegan en Inglaterra.",
"The striker is Norwegian.": "El delantero es noruego.",
"The winger is English.": "El extremo es inglés.",
"Three players are from Real Madrid.": "Tres jugadores son del Real Madrid.",
"The goalkeeper is Belgian.": "El portero es belga.",
"The winger is Brazilian.": "El extremo es brasileño.",
"The striker plays in Spain but not for Real Madrid.": "El delantero juega en España, pero no en el Real Madrid.",
"The revealed player plays in Spain.": "El jugador revelado juega en España.",
"Two players are current Premier League players.": "Dos jugadores juegan actualmente en la Premier League.",
"The defender is French.": "El defensa es francés.",
"The striker plays in Germany.": "El delantero juega en Alemania.",
"The midfielder plays for Napoli.": "El centrocampista juega en el Napoli.",
},
},
fr: {
allCompleted: "Tous les puzzles sont terminés.",
available: "disponible",
browsePlayers: (position) => `Voir tous les joueurs ${position}`,
close: "Proche",
closeModal: "Fermer la liste des joueurs",
copied: "Copié",
copy: "Copier",
dailySubtitle: "Puzzle football quotidien à cinq joueurs",
difficulty: { easy: "Facile", medium: "Moyen", hard: "Difficile" },
exact: "Exact",
fixed: "fixe",
field: "Terrain",
hint: "Indice",
hide: "Masquer",
locked: "verrouillé",
miss: "Raté",
noAttemptsLeft: "Plus d'essais.",
pickAndValidate: (position) => `Choisis un ${position} et valide l'équipe.`,
playerListTitle: (position) => `Joueurs ${position}`,
playerSearch: "Recherche joueur",
playersAvailable: (count) => `${count} disponibles`,
puzzle: "Puzzle",
puzzleFinished: "Puzzle terminé.",
searchPlaceholder: (position) => `Chercher un joueur pour ${position}`,
select: "Choisir",
share: "Partager",
shareLead: "Résultat du jour prêt",
stats: "Stats",
statsLead: "Reviens demain pour garder ta série.",
streak: "Série",
maxStreak: "Meilleure série",
played: "Joués",
perfectDays: "Jours parfaits",
averageTime: "Temps moyen",
bestTime: "Meilleur temps",
distribution: "Distribution",
solvePrevious: "Résous le puzzle précédent pour déverrouiller cette équipe.",
solved: "résolu",
time: "Temps",
tries: "essais",
unlocked: (difficulty) => `${difficulty} déverrouillé.`,
validate: "Valider",
reset: "Reset",
reveal: "Révéler",
show: "Afficher",
left: (count) => `${count} restants`,
completeLineup: "Complète l'équipe avant de valider.",
attemptsRemaining: (count) => `${count} essais restants.`,
clueTitle: "Indices",
language: "Langue",
noHintAvailable: "Aucun indice disponible.",
hintRevealed: (position) => `${position} révélé.`,
clue: {},
},
de: {
allCompleted: "Alle Rätsel abgeschlossen.",
available: "verfügbar",
browsePlayers: (position) => `Alle ${position}-Spieler anzeigen`,
close: "Nah",
closeModal: "Spielerliste schließen",
copied: "Kopiert",
copy: "Kopieren",
dailySubtitle: "Tägliches Fußballrätsel mit fünf Spielern",
difficulty: { easy: "Leicht", medium: "Mittel", hard: "Schwer" },
exact: "Exakt",
fixed: "fix",
field: "Feld",
hint: "Tipp",
hide: "Ausblenden",
locked: "gesperrt",
miss: "Fehl",
noAttemptsLeft: "Keine Versuche übrig.",
pickAndValidate: (position) => `Wähle einen ${position} und prüfe die Aufstellung.`,
playerListTitle: (position) => `${position}-Spieler`,
playerSearch: "Spieler suchen",
playersAvailable: (count) => `${count} verfügbar`,
puzzle: "Puzzle",
puzzleFinished: "Rätsel beendet.",
searchPlaceholder: (position) => `Spieler für ${position} suchen`,
select: "Wählen",
share: "Teilen",
shareLead: "Tagesergebnis bereit",
stats: "Stats",
statsLead: "Komm morgen zurück, um die Serie zu halten.",
streak: "Serie",
maxStreak: "Beste Serie",
played: "Gespielt",
perfectDays: "Perfekte Tage",
averageTime: "Ø Zeit",
bestTime: "Beste Zeit",
distribution: "Verteilung",
solvePrevious: "Löse das vorherige Rätsel, um diese Aufstellung freizuschalten.",
solved: "gelöst",
time: "Zeit",
tries: "Versuche",
unlocked: (difficulty) => `${difficulty} freigeschaltet.`,
validate: "Prüfen",
reset: "Reset",
reveal: "Aufdecken",
show: "Anzeigen",
left: (count) => `${count} übrig`,
completeLineup: "Vervollständige die Aufstellung vor dem Prüfen.",
attemptsRemaining: (count) => `${count} Versuche übrig.`,
clueTitle: "Hinweise",
language: "Sprache",
noHintAvailable: "Keine Tipps verfügbar.",
hintRevealed: (position) => `${position} aufgedeckt.`,
clue: {},
},
it: {
allCompleted: "Tutti i puzzle completati.",
available: "disponibile",
browsePlayers: (position) => `Vedi tutti i giocatori ${position}`,
close: "Vicino",
closeModal: "Chiudi lista giocatori",
copied: "Copiato",
copy: "Copia",
dailySubtitle: "Puzzle calcistico quotidiano da cinque giocatori",
difficulty: { easy: "Facile", medium: "Medio", hard: "Difficile" },
exact: "Esatto",
fixed: "fisso",
field: "Campo",
hint: "Aiuto",
hide: "Nascondi",
locked: "bloccato",
miss: "Errore",
noAttemptsLeft: "Nessun tentativo rimasto.",
pickAndValidate: (position) => `Scegli un ${position} e valida la formazione.`,
playerListTitle: (position) => `Giocatori ${position}`,
playerSearch: "Cerca giocatore",
playersAvailable: (count) => `${count} disponibili`,
puzzle: "Puzzle",
puzzleFinished: "Puzzle finito.",
searchPlaceholder: (position) => `Cerca giocatore per ${position}`,
select: "Scegli",
share: "Condividi",
shareLead: "Risultato giornaliero pronto",
stats: "Stats",
statsLead: "Torna domani per mantenere la serie.",
streak: "Serie",
maxStreak: "Miglior serie",
played: "Giocati",
perfectDays: "Giorni perfetti",
averageTime: "Tempo medio",
bestTime: "Miglior tempo",
distribution: "Distribuzione",
solvePrevious: "Risolvi il puzzle precedente per sbloccare questa formazione.",
solved: "risolto",
time: "Tempo",
tries: "tentativi",
unlocked: (difficulty) => `${difficulty} sbloccato.`,
validate: "Valida",
reset: "Reset",
reveal: "Rivela",
show: "Mostra",
left: (count) => `${count} rimasti`,
completeLineup: "Completa la formazione prima di validare.",
attemptsRemaining: (count) => `${count} tentativi rimasti.`,
clueTitle: "Indizi",
language: "Lingua",
noHintAvailable: "Nessun aiuto disponibile.",
hintRevealed: (position) => `${position} rivelato.`,
clue: {},
},
pt: {
allCompleted: "Todos os puzzles concluídos.",
available: "disponível",
browsePlayers: (position) => `Ver todos os jogadores ${position}`,
close: "Perto",
closeModal: "Fechar lista de jogadores",
copied: "Copiado",
copy: "Copiar",
dailySubtitle: "Puzzle diário de futebol com cinco jogadores",
difficulty: { easy: "Fácil", medium: "Médio", hard: "Difícil" },
exact: "Exato",
fixed: "fixo",
field: "Campo",
hint: "Dica",
hide: "Ocultar",
locked: "bloqueado",
miss: "Falha",
noAttemptsLeft: "Sem tentativas restantes.",
pickAndValidate: (position) => `Escolhe um ${position} e valida o onze.`,
playerListTitle: (position) => `Jogadores ${position}`,
playerSearch: "Pesquisar jogador",
playersAvailable: (count) => `${count} disponíveis`,
puzzle: "Puzzle",
puzzleFinished: "Puzzle terminado.",
searchPlaceholder: (position) => `Pesquisar jogador para ${position}`,
select: "Escolher",
share: "Partilhar",
shareLead: "Resultado diário pronto",
stats: "Stats",
statsLead: "Volta amanhã para manter a sequência.",
streak: "Sequência",
maxStreak: "Melhor sequência",
played: "Jogados",
perfectDays: "Dias perfeitos",
averageTime: "Tempo médio",
bestTime: "Melhor tempo",
distribution: "Distribuição",
solvePrevious: "Resolve o puzzle anterior para desbloquear este onze.",
solved: "resolvido",
time: "Tempo",
tries: "tentativas",
unlocked: (difficulty) => `${difficulty} desbloqueado.`,
validate: "Validar",
reset: "Reset",
reveal: "Revelar",
show: "Mostrar",
left: (count) => `${count} restantes`,
completeLineup: "Completa o onze antes de validar.",
attemptsRemaining: (count) => `${count} tentativas restantes.`,
clueTitle: "Pistas",
language: "Idioma",
noHintAvailable: "Sem dicas disponíveis.",
hintRevealed: (position) => `${position} revelado.`,
clue: {},
},
};
export function detectLocale(language?: string): Locale {
const code = language?.toLowerCase().split("-")[0];
if (code === "es" || code === "fr" || code === "de" || code === "it" || code === "pt") return code;
return "en";
}
export function getDictionary(locale: Locale): Dictionary {
const dictionary = dictionaries[locale];
const fallback = dictionaries.en;
return {
...fallback,
...dictionary,
clue: {
...fallback.clue,
...dictionary.clue,
},
};
}
+69
View File
@@ -0,0 +1,69 @@
import type { Locale } from "./i18n";
type Quote = {
author: string;
text: Record<Locale, string>;
};
const quotes: Quote[] = [
{
author: "Johan Cruyff",
text: {
en: "Playing football is simple, but playing simple football is the hardest thing.",
es: "Jugar al fútbol es simple, pero jugar simple es lo más difícil.",
fr: "Jouer au football est simple, mais jouer simple est le plus difficile.",
de: "Fußball zu spielen ist einfach, einfach Fußball zu spielen ist das Schwerste.",
it: "Giocare a calcio è semplice, ma giocare semplice è la cosa più difficile.",
pt: "Jogar futebol é simples, mas jogar simples é o mais difícil.",
},
},
{
author: "Pep Guardiola",
text: {
en: "The secret is to take the ball and pass it.",
es: "El secreto es coger el balón y pasarlo.",
fr: "Le secret, c'est de prendre le ballon et de le passer.",
de: "Das Geheimnis ist, den Ball zu nehmen und ihn weiterzuspielen.",
it: "Il segreto è prendere il pallone e passarlo.",
pt: "O segredo é pegar na bola e passá-la.",
},
},
{
author: "Sir Alex Ferguson",
text: {
en: "Attack wins you games, defence wins you titles.",
es: "El ataque gana partidos, la defensa gana títulos.",
fr: "L'attaque gagne des matchs, la défense gagne des titres.",
de: "Angriff gewinnt Spiele, Verteidigung gewinnt Titel.",
it: "L'attacco vince le partite, la difesa vince i titoli.",
pt: "O ataque ganha jogos, a defesa ganha títulos.",
},
},
{
author: "Arrigo Sacchi",
text: {
en: "Football is the most important of the least important things.",
es: "El fútbol es la cosa más importante de las menos importantes.",
fr: "Le football est la plus importante des choses les moins importantes.",
de: "Fußball ist die wichtigste der unwichtigen Sachen.",
it: "Il calcio è la cosa più importante tra le meno importanti.",
pt: "O futebol é a mais importante das coisas menos importantes.",
},
},
{
author: "Mia Hamm",
text: {
en: "Celebrate what you've accomplished, but raise the bar each time.",
es: "Celebra lo logrado, pero sube el listón cada vez.",
fr: "Célèbre ce que tu as accompli, puis élève encore le niveau.",
de: "Feiere, was du erreicht hast, und leg die Messlatte höher.",
it: "Celebra ciò che hai fatto, poi alza ancora l'asticella.",
pt: "Celebra o que conquistaste, mas sobe a fasquia sempre.",
},
},
];
export function quoteForDay(dateKey: string, locale: Locale): Quote {
const value = dateKey.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0);
return quotes[value % quotes.length];
}
+36
View File
@@ -0,0 +1,36 @@
import { DIFFICULTIES, getPuzzleNumber, MAX_ATTEMPTS, POSITIONS } from "./game-engine";
import type { Dictionary } from "./i18n";
import { formatElapsed } from "./time";
import type { DailyProgress, Difficulty } from "./types";
const emoji = {
correct: "🟩",
partial: "🟨",
wrong: "⬛",
empty: "⬛",
};
export function buildShareText(dateKey: string, progress: DailyProgress, t: Dictionary): string {
const rows = DIFFICULTIES.map((difficulty) => {
const item = progress[difficulty];
const label = t.difficulty[difficulty];
if (item.locked) return `${label.padEnd(8)} 🔒`;
const result = item.solved ? `${item.attempts.length}/${MAX_ATTEMPTS}` : `X/${MAX_ATTEMPTS}`;
const time = formatElapsed(item.elapsedSeconds);
const grids = item.attempts.length
? item.attempts
.map((attempt) =>
POSITIONS.map((position) => {
const slot = attempt.slots.find((candidate) => candidate.position === position);
return emoji[slot?.feedback ?? "empty"];
}).join(""),
)
.join("\n")
: "⬛⬛⬛⬛⬛";
return `${label} ${result} · ${time}\n${grids}`;
});
return [`hidden11 #${getPuzzleNumber(dateKey)}`, "", ...rows, "", "https://hidden11.app"].join("\n");
}
+142
View File
@@ -0,0 +1,142 @@
import { DIFFICULTIES } from "./game-engine";
import type { DailyProgress, Difficulty, GlobalStats } from "./types";
const STATS_KEY = "hidden11-global-stats";
export function getStorageKey(dateKey: string): string {
return `hidden11-progress-${dateKey}`;
}
export function createInitialProgress(): DailyProgress {
return DIFFICULTIES.reduce<DailyProgress>((progress, difficulty, index) => {
progress[difficulty] = {
attempts: [],
elapsedSeconds: 0,
solved: false,
locked: index !== 0,
};
return progress;
}, {} as DailyProgress);
}
export function loadProgress(dateKey: string): DailyProgress {
if (typeof window === "undefined") return createInitialProgress();
try {
const raw = window.localStorage.getItem(getStorageKey(dateKey));
if (!raw) return createInitialProgress();
return normalizeProgress(JSON.parse(raw) as DailyProgress);
} catch {
return createInitialProgress();
}
}
export function saveProgress(dateKey: string, progress: DailyProgress): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(getStorageKey(dateKey), JSON.stringify(progress));
}
export function createInitialStats(): GlobalStats {
return {
averageSeconds: 0,
bestSeconds: null,
currentStreak: 0,
gamesPlayed: 0,
lastCompletedDate: null,
maxStreak: 0,
perfectDays: 0,
totalSeconds: 0,
winDistribution: {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
6: 0,
7: 0,
},
};
}
export function loadStats(): GlobalStats {
if (typeof window === "undefined") return createInitialStats();
try {
const raw = window.localStorage.getItem(STATS_KEY);
if (!raw) return createInitialStats();
return normalizeStats(JSON.parse(raw) as GlobalStats);
} catch {
return createInitialStats();
}
}
export function saveStats(stats: GlobalStats): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(STATS_KEY, JSON.stringify(stats));
}
export function recordDailyStats(dateKey: string, progress: DailyProgress): GlobalStats {
const current = loadStats();
if (current.lastCompletedDate === dateKey) return current;
const solvedCount = DIFFICULTIES.filter((difficulty) => progress[difficulty].solved).length;
const totalSeconds = DIFFICULTIES.reduce((sum, difficulty) => sum + progress[difficulty].elapsedSeconds, 0);
const attemptsUsed = DIFFICULTIES.reduce((sum, difficulty) => sum + progress[difficulty].attempts.length, 0);
const bucket = solvedCount === DIFFICULTIES.length ? Math.min(6, Math.max(1, attemptsUsed)) : 7;
const previousDate = current.lastCompletedDate ? new Date(`${current.lastCompletedDate}T00:00:00.000Z`) : null;
const today = new Date(`${dateKey}T00:00:00.000Z`);
const daysSincePrevious = previousDate ? Math.round((today.getTime() - previousDate.getTime()) / 86400000) : null;
const currentStreak = daysSincePrevious === 1 ? current.currentStreak + 1 : 1;
const gamesPlayed = current.gamesPlayed + 1;
const nextTotalSeconds = current.totalSeconds + totalSeconds;
const next: GlobalStats = {
...current,
averageSeconds: Math.round(nextTotalSeconds / gamesPlayed),
bestSeconds: current.bestSeconds === null ? totalSeconds : Math.min(current.bestSeconds, totalSeconds),
currentStreak,
gamesPlayed,
lastCompletedDate: dateKey,
maxStreak: Math.max(current.maxStreak, currentStreak),
perfectDays: current.perfectDays + (solvedCount === DIFFICULTIES.length ? 1 : 0),
totalSeconds: nextTotalSeconds,
winDistribution: {
...current.winDistribution,
[bucket]: (current.winDistribution[bucket] ?? 0) + 1,
},
};
saveStats(next);
return next;
}
export function nextDifficulty(difficulty: Difficulty): Difficulty | null {
if (difficulty === "easy") return "medium";
if (difficulty === "medium") return "hard";
return null;
}
function normalizeProgress(progress: DailyProgress): DailyProgress {
const initial = createInitialProgress();
DIFFICULTIES.forEach((difficulty) => {
initial[difficulty] = {
attempts: progress[difficulty]?.attempts ?? [],
elapsedSeconds: progress[difficulty]?.elapsedSeconds ?? 0,
solved: progress[difficulty]?.solved ?? false,
locked: progress[difficulty]?.locked ?? difficulty !== "easy",
};
});
return initial;
}
function normalizeStats(stats: GlobalStats): GlobalStats {
const initial = createInitialStats();
return {
...initial,
...stats,
winDistribution: {
...initial.winDistribution,
...(stats.winDistribution ?? {}),
},
};
}
+5
View File
@@ -0,0 +1,5 @@
export function formatElapsed(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}:${remainder.toString().padStart(2, "0")}`;
}
+54
View File
@@ -0,0 +1,54 @@
export type Position = "GK" | "DEF" | "MID" | "WING" | "ST";
export type Difficulty = "easy" | "medium" | "hard";
export type Feedback = "correct" | "partial" | "wrong" | "empty";
export type Player = {
id: string;
name: string;
position: Position;
nationality: string;
club: string;
league: string;
};
export type Puzzle = {
id: string;
date: string;
difficulty: Difficulty;
solution: Record<Position, string>;
fixedPosition: Position;
clues: string[];
};
export type SlotGuess = {
position: Position;
playerId: string | null;
feedback: Feedback;
};
export type Guess = {
slots: SlotGuess[];
};
export type PuzzleProgress = {
attempts: Guess[];
elapsedSeconds: number;
solved: boolean;
locked: boolean;
};
export type DailyProgress = Record<Difficulty, PuzzleProgress>;
export type GlobalStats = {
averageSeconds: number;
bestSeconds: number | null;
currentStreak: number;
gamesPlayed: number;
lastCompletedDate: string | null;
maxStreak: number;
perfectDays: number;
totalSeconds: number;
winDistribution: Record<number, number>;
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;
+6282
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "hidden11",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"validate:data": "node scripts/validate-puzzles.mjs",
"lint": "next lint"
},
"dependencies": {
"next": "15.5.15",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"overrides": {
"postcss": "8.5.10"
},
"devDependencies": {
"@types/node": "20.17.16",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"autoprefixer": "10.4.20",
"eslint": "8.57.1",
"eslint-config-next": "15.5.15",
"postcss": "8.5.10",
"tailwindcss": "3.4.17",
"typescript": "5.7.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+1
View File
@@ -0,0 +1 @@
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title">
<title id="title">hidden11 icon</title>
<defs>
<linearGradient id="oneA" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#00d46a"/>
<stop offset="1" stop-color="#22c55e"/>
</linearGradient>
<linearGradient id="oneB" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#ffe34d"/>
<stop offset="1" stop-color="#facc15"/>
</linearGradient>
</defs>
<rect width="160" height="160" rx="32" fill="#061b12"/>
<g transform="translate(16 24)">
<path d="M10 19l42-9v106H27V41l-17 4z" fill="#f8fff9"/>
<circle cx="56" cy="16" r="12" fill="#f8fff9"/>
<path d="M27 116h25V91z" fill="url(#oneA)"/>
</g>
<g transform="translate(84 24)">
<path d="M10 19l42-9v106H27V41l-17 4z" fill="#f8fff9"/>
<circle cx="56" cy="16" r="12" fill="#f8fff9"/>
<path d="M27 116h25V91z" fill="url(#oneB)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 976 B

+36
View File
@@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 475 120" role="img" aria-labelledby="title">
<title id="title">hidden11</title>
<defs>
<linearGradient id="oneA" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#00d46a"/>
<stop offset="1" stop-color="#22c55e"/>
</linearGradient>
<linearGradient id="oneB" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#ffe34d"/>
<stop offset="1" stop-color="#facc15"/>
</linearGradient>
</defs>
<rect width="475" height="120" fill="none"/>
<text
x="8"
y="82"
fill="#f8fff9"
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
font-size="76"
font-weight="900"
letter-spacing="0"
>hidden</text>
<g transform="translate(310 17)">
<path d="M18 16l42-9v87H35V38l-17 4z" fill="#f8fff9"/>
<circle cx="64" cy="13" r="12" fill="#f8fff9"/>
<path d="M35 94h25V69z" fill="url(#oneA)"/>
</g>
<g transform="translate(385 17)">
<path d="M18 16l42-9v87H35V38l-17 4z" fill="#f8fff9"/>
<circle cx="64" cy="13" r="12" fill="#f8fff9"/>
<path d="M35 94h25V69z" fill="url(#oneB)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<radialGradient id="bg" cx="50%" cy="35%" r="70%">
<stop offset="0" stop-color="#115c39"/>
<stop offset="0.55" stop-color="#061b12"/>
<stop offset="1" stop-color="#020806"/>
</radialGradient>
</defs>
<rect width="1200" height="630" fill="url(#bg)"/>
<rect x="92" y="82" width="1016" height="466" rx="28" fill="none" stroke="#ffffff" stroke-opacity="0.16" stroke-width="3"/>
<text x="600" y="252" text-anchor="middle" fill="#f8fff9" font-family="Inter, Arial, sans-serif" font-size="118" font-weight="900">hidden11</text>
<text x="600" y="372" text-anchor="middle" fill="#f8fafc" font-family="Inter, Arial, sans-serif" font-size="42" font-weight="800">Daily football lineup puzzle</text>
<text x="600" y="438" text-anchor="middle" fill="#d1fae5" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="700">Find the hidden five-player XI</text>
<g transform="translate(472 482)">
<rect width="52" height="52" rx="8" fill="#34d399"/>
<rect x="68" width="52" height="52" rx="8" fill="#facc15"/>
<rect x="136" width="52" height="52" rx="8" fill="#64748b"/>
<rect x="204" width="52" height="52" rx="8" fill="#34d399"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+56
View File
@@ -0,0 +1,56 @@
import players from "../data/players.json" with { type: "json" };
import puzzles from "../data/puzzles.json" with { type: "json" };
const byId = new Map(players.map((player) => [player.id, player]));
function solutionPlayers(puzzle) {
return Object.entries(puzzle.solution).map(([position, playerId]) => {
const player = byId.get(playerId);
if (!player) throw new Error(`${puzzle.id}: missing player ${playerId}`);
return { slot: position, ...player };
});
}
function count(items, predicate) {
return items.filter(predicate).length;
}
function clubGroups(items) {
return Object.values(
items.reduce((groups, player) => {
groups[player.club] ??= [];
groups[player.club].push(player);
return groups;
}, {}),
);
}
for (const puzzle of puzzles) {
const lineup = solutionPlayers(puzzle);
const clueText = puzzle.clues.join(" | ");
if (clueText.includes("Two players share the same club.")) {
const exactPairs = clubGroups(lineup).filter((group) => group.length === 2);
const biggerGroups = clubGroups(lineup).filter((group) => group.length > 2);
if (exactPairs.length !== 1 || biggerGroups.length !== 0) {
throw new Error(`${puzzle.id}: expected exactly one club pair of two`);
}
}
if (clueText.includes("Four players play in England.")) {
const total = count(lineup, (player) => player.league === "Premier League");
if (total !== 4) throw new Error(`${puzzle.id}: expected exactly four Premier League players, got ${total}`);
}
if (clueText.includes("Three players are from Real Madrid.")) {
const total = count(lineup, (player) => player.club === "Real Madrid");
if (total !== 3) throw new Error(`${puzzle.id}: expected exactly three Real Madrid players, got ${total}`);
}
if (clueText.includes("Two players are current Premier League players.")) {
const total = count(lineup, (player) => player.league === "Premier League");
if (total !== 2) throw new Error(`${puzzle.id}: expected exactly two Premier League players, got ${total}`);
}
}
console.log("Puzzle data OK");
+25
View File
@@ -0,0 +1,25 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
pitch: {
950: "#061b12",
900: "#082619",
800: "#0c3a25",
700: "#115c39",
500: "#22a05a"
}
}
},
},
plugins: [],
};
export default config;
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}