Initial hidden11 MVP
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
deploy
|
||||
README.md
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: hidden11
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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 ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+6282
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user