Compare commits

...

15 Commits

Author SHA1 Message Date
thibaud-leclere
1fd6dcc5d3 fix: add 16px left margin to popover to avoid screen edge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:54:59 +02:00
thibaud-leclere
0706d99c82 fix: force popover to left placement with soft wrap when space is limited
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:53:58 +02:00
thibaud-leclere
273ea49ed0 fix: generate hints about the row actor, not the main actor
Hints should help identify the row actor (to find the highlighted letter),
not reveal the main actor directly. Simplified hint generation: no shared
exclusion pools needed since each row has a different actor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:51:22 +02:00
thibaud-leclere
8d413b5c57 fix: review fixes — cache Wikidata calls, add timeout, improve escaping, hide empty popovers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:38:05 +02:00
thibaud-leclere
91f45448f0 feat: replace ? button with hint type icons in ActorPopover 2026-03-30 22:35:39 +02:00
thibaud-leclere
42a3567e1c feat: resolve hint display text in computeGridData 2026-03-30 22:33:51 +02:00
thibaud-leclere
32ae77da53 feat: generate hints per row in GameGridGenerator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:32:51 +02:00
thibaud-leclere
c2efdd4eeb feat: add WikidataAwardGateway for actor awards from Wikidata SPARQL 2026-03-30 22:31:42 +02:00
thibaud-leclere
7f3738007d feat: add findOneRandomByActor to MovieRoleRepository 2026-03-30 22:31:07 +02:00
thibaud-leclere
e3ee26e070 feat: add hintType and hintData columns to GameRow entity
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 22:30:30 +02:00
thibaud-leclere
cdcd3312ef docs: add game hints implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:26:32 +02:00
thibaud-leclere
4fb1a25469 docs: add game hints system design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:21:26 +02:00
thibaud-leclere
335a55562f feat: handle non-letter characters in actor names with separator rows
Display spaces, hyphens and other non-letter characters as static cells
instead of input fields, and add separator rows in the grid for
non-alphabetic characters in the main actor's name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:59:11 +02:00
thibaud-leclere
ba9a3fba5d feat: replace code icon with Git logo in navbar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:58:43 +02:00
thibaud-leclere
86bf2eb1d3 feat: move hints column to the left of the game grid
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:33:40 +02:00
15 changed files with 1036 additions and 58 deletions

View File

@@ -1,2 +1,3 @@
import '@fortawesome/fontawesome-free/css/all.min.css';
import './bootstrap.js';
import './styles/app.css';

View File

@@ -1,20 +1,40 @@
import React, { useState } from 'react';
import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react';
import { useFloating, useClick, useDismiss, useInteractions, offset, shift, size } from '@floating-ui/react';
export default function ActorPopover({ actorName }) {
const HINT_ICONS = {
film: 'fa-solid fa-film',
character: 'fa-solid fa-masks-theater',
award: 'fa-solid fa-trophy',
};
export default function ActorPopover({ hintType, hintText }) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(8), flip(), shift()],
placement: 'top',
middleware: [
offset(8),
size({
apply({ availableWidth, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${availableWidth - 16}px`,
});
},
}),
shift({ padding: 8 }),
],
placement: 'left',
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
if (!hintText) return null;
const iconClass = HINT_ICONS[hintType] || 'fa-solid fa-circle-question';
return (
<>
<button
@@ -23,7 +43,7 @@ export default function ActorPopover({ actorName }) {
className="popover-trigger"
type="button"
>
?
<i className={iconClass}></i>
</button>
{isOpen && (
<div
@@ -32,7 +52,7 @@ export default function ActorPopover({ actorName }) {
{...getFloatingProps()}
className="actor-popover"
>
<strong>{actorName}</strong>
{hintText}
</div>
)}
</>

View File

@@ -5,15 +5,36 @@ export default function GameGrid({ grid, width, middle }) {
return (
<table id="actors">
<tbody>
{grid.map((row, rowIndex) => (
{grid.map((row, rowIndex) => {
if (row.separator !== undefined) {
return (
<tr key={rowIndex} className="separator-row">
<td />
{Array.from({ length: middle }, (_, i) => (
<td key={i} />
))}
<td className="letter-static separator-char">
{row.separator === ' ' ? '' : row.separator}
</td>
{Array.from({ length: width - middle }, (_, i) => (
<td key={middle + 1 + i} />
))}
</tr>
);
}
return (
<GameRow
key={rowIndex}
actorName={row.actorName}
pos={row.pos}
colStart={middle - row.pos}
totalWidth={width}
hintType={row.hintType}
hintText={row.hintText}
/>
))}
);
})}
</tbody>
</table>
);

View File

@@ -1,22 +1,37 @@
import React, { useRef, useCallback } from 'react';
import React, { useRef, useCallback, useMemo } from 'react';
import LetterInput from './LetterInput';
import ActorPopover from './ActorPopover';
export default function GameRow({ actorName, pos, colStart, totalWidth }) {
function isLetter(ch) {
return /[a-zA-Z]/.test(ch);
}
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
const inputRefs = useRef([]);
const letters = actorName.split('');
const letterIndices = useMemo(
() => letters.reduce((acc, ch, i) => { if (isLetter(ch)) acc.push(i); return acc; }, []),
[actorName]
);
const setInputRef = useCallback((index) => (el) => {
inputRefs.current[index] = el;
}, []);
const focusInput = useCallback((index) => {
inputRefs.current[index]?.focus();
}, []);
const letters = actorName.split('');
const focusNextInput = useCallback((charIndex, direction) => {
const currentPos = letterIndices.indexOf(charIndex);
const nextPos = currentPos + direction;
if (nextPos >= 0 && nextPos < letterIndices.length) {
inputRefs.current[letterIndices[nextPos]]?.focus();
}
}, [letterIndices]);
return (
<tr>
<td>
<ActorPopover hintType={hintType} hintText={hintText} />
</td>
{Array.from({ length: totalWidth + 1 }, (_, colIndex) => {
const charIndex = colIndex - colStart;
const isInRange = charIndex >= 0 && charIndex < letters.length;
@@ -25,19 +40,26 @@ export default function GameRow({ actorName, pos, colStart, totalWidth }) {
return <td key={colIndex} />;
}
const ch = letters[charIndex];
if (!isLetter(ch)) {
return (
<td key={colIndex} className="letter-static">
{ch}
</td>
);
}
return (
<LetterInput
key={colIndex}
highlighted={charIndex === pos}
inputRef={setInputRef(charIndex)}
onNext={() => focusInput(charIndex + 1)}
onPrev={() => focusInput(charIndex - 1)}
onNext={() => focusNextInput(charIndex, 1)}
onPrev={() => focusNextInput(charIndex, -1)}
/>
);
})}
<td>
<ActorPopover actorName={actorName} />
</td>
</tr>
);
}

View File

@@ -75,6 +75,27 @@ body {
color: var(--orange);
}
.letter-static {
width: 38px;
height: 38px;
text-align: center;
font-family: 'Inter', sans-serif;
font-size: 15px;
font-weight: 700;
text-transform: uppercase;
color: var(--text);
vertical-align: middle;
}
.separator-row td {
height: 12px;
padding: 0;
}
.separator-char {
height: 12px;
}
/* ── Popover ── */
.popover-trigger {
@@ -109,6 +130,8 @@ body {
z-index: 100;
font-size: 13px;
color: var(--text);
overflow-wrap: break-word;
word-wrap: break-word;
}
/* ── Auth pages ── */

View File

@@ -0,0 +1,571 @@
# Game Hints System Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Each game row displays a typed hint (film, character, award) about the main actor, replacing the "?" popover button with a type-specific icon.
**Architecture:** Two new columns (`hint_type`, `hint_data`) on `GameRow`. Hints are generated at game creation time in `GameGridGenerator`. A new `WikidataAwardGateway` service fetches awards via SPARQL. The React `ActorPopover` component shows a type icon instead of "?" and displays the resolved hint text in the popover.
**Tech Stack:** Symfony 8 / Doctrine ORM (backend), React 19 (frontend), Wikidata SPARQL API (awards), Font Awesome (icons)
---
### Task 1: Add `hintType` and `hintData` columns to `GameRow` entity
**Files:**
- Modify: `src/Entity/GameRow.php`
- [ ] **Step 1: Add properties and mapping to `GameRow`**
Add after the `$rowOrder` property (line 30):
```php
#[ORM\Column(length: 20)]
private ?string $hintType = null;
#[ORM\Column(length: 255)]
private ?string $hintData = null;
```
Add getters and setters after `setRowOrder()`:
```php
public function getHintType(): ?string
{
return $this->hintType;
}
public function setHintType(string $hintType): static
{
$this->hintType = $hintType;
return $this;
}
public function getHintData(): ?string
{
return $this->hintData;
}
public function setHintData(string $hintData): static
{
$this->hintData = $hintData;
return $this;
}
```
- [ ] **Step 2: Generate and run migration**
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
Expected: new migration adding `hint_type` and `hint_data` columns to `game_row`.
- [ ] **Step 3: Commit**
```bash
git add src/Entity/GameRow.php migrations/
git commit -m "feat: add hintType and hintData columns to GameRow entity"
```
---
### Task 2: Add `MovieRoleRepository::findOneRandomByActor()` method
**Files:**
- Modify: `src/Repository/MovieRoleRepository.php`
- [ ] **Step 1: Write the repository method**
Replace the commented-out methods with:
```php
/**
* @param list<int> $excludeMovieRoleIds MovieRole IDs to exclude
* @return MovieRole|null
*/
public function findOneRandomByActor(int $actorId, array $excludeMovieRoleIds = []): ?MovieRole
{
$qb = $this->createQueryBuilder('mr')
->andWhere('mr.actor = :actorId')
->setParameter('actorId', $actorId);
if (!empty($excludeMovieRoleIds)) {
$qb->andWhere('mr.id NOT IN (:excludeIds)')
->setParameter('excludeIds', $excludeMovieRoleIds);
}
return $qb
->orderBy('RANDOM()')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Repository/MovieRoleRepository.php
git commit -m "feat: add findOneRandomByActor to MovieRoleRepository"
```
---
### Task 3: Create `WikidataAwardGateway` service
**Files:**
- Create: `src/Service/WikidataAwardGateway.php`
- [ ] **Step 1: Create the gateway service**
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class WikidataAwardGateway
{
private const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
public function __construct(
private readonly HttpClientInterface $httpClient,
) {}
/**
* Fetch awards for an actor from Wikidata.
*
* @return list<array{name: string, year: int}>
*/
public function getAwards(Actor $actor): array
{
$sparql = $this->buildQuery($actor->getName());
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
'query' => [
'query' => $sparql,
'format' => 'json',
],
'headers' => [
'Accept' => 'application/sparql-results+json',
'User-Agent' => 'LtbxdActorle/1.0',
],
]);
$data = $response->toArray();
$awards = [];
foreach ($data['results']['bindings'] ?? [] as $binding) {
$name = $binding['awardLabel']['value'] ?? null;
$year = $binding['year']['value'] ?? null;
if ($name && $year) {
$awards[] = [
'name' => $name,
'year' => (int) substr($year, 0, 4),
];
}
}
return $awards;
}
private function buildQuery(string $actorName): string
{
$escaped = str_replace('"', '\\"', $actorName);
return <<<SPARQL
SELECT ?awardLabel ?year WHERE {
?person rdfs:label "{$escaped}"@en .
?person wdt:P31 wd:Q5 .
?person p:P166 ?awardStatement .
?awardStatement ps:P166 ?award .
?awardStatement pq:P585 ?date .
BIND(YEAR(?date) AS ?year)
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
}
ORDER BY DESC(?year)
SPARQL;
}
}
```
- [ ] **Step 2: Verify the service is autowired**
```bash
php bin/console debug:container WikidataAwardGateway
```
Expected: service `App\Service\WikidataAwardGateway` is listed.
- [ ] **Step 3: Commit**
```bash
git add src/Service/WikidataAwardGateway.php
git commit -m "feat: add WikidataAwardGateway for actor awards from Wikidata SPARQL"
```
---
### Task 4: Integrate hint generation into `GameGridGenerator`
**Files:**
- Modify: `src/Service/GameGridGenerator.php`
- [ ] **Step 1: Add dependencies to constructor**
Update the constructor at line 16-19:
```php
public function __construct(
private readonly ActorRepository $actorRepository,
private readonly MovieRoleRepository $movieRoleRepository,
private readonly WikidataAwardGateway $wikidataAwardGateway,
private readonly EntityManagerInterface $em,
) {}
```
Add the import at the top:
```php
use App\Repository\MovieRoleRepository;
```
- [ ] **Step 2: Add hint generation methods**
Add these private methods after `computeGridData()`:
```php
/**
* @param list<int> $usedMovieRoleIds MovieRole IDs already used (for DB exclusion)
* @param list<string> $usedHintKeys Semantic keys like "film:42" to avoid duplicate hints
* @return array{type: string, data: string}|null
*/
private function generateHint(Actor $mainActor, array &$usedMovieRoleIds, array &$usedHintKeys): ?array
{
$types = ['film', 'character', 'award'];
shuffle($types);
foreach ($types as $type) {
$hint = $this->resolveHint($type, $mainActor, $usedMovieRoleIds, $usedHintKeys);
if ($hint !== null) {
return $hint;
}
}
return null;
}
/**
* @param list<int> $usedMovieRoleIds
* @param list<string> $usedHintKeys
* @return array{type: string, data: string}|null
*/
private function resolveHint(string $type, Actor $mainActor, array &$usedMovieRoleIds, array &$usedHintKeys): ?array
{
switch ($type) {
case 'film':
$role = $this->movieRoleRepository->findOneRandomByActor(
$mainActor->getId(),
$usedMovieRoleIds,
);
if ($role === null) {
return null;
}
$movieId = (string) $role->getMovie()->getId();
$key = 'film:' . $movieId;
if (in_array($key, $usedHintKeys)) {
return null;
}
$usedMovieRoleIds[] = $role->getId();
$usedHintKeys[] = $key;
return ['type' => 'film', 'data' => $movieId];
case 'character':
$role = $this->movieRoleRepository->findOneRandomByActor(
$mainActor->getId(),
$usedMovieRoleIds,
);
if ($role === null) {
return null;
}
$roleId = (string) $role->getId();
$key = 'character:' . $roleId;
if (in_array($key, $usedHintKeys)) {
return null;
}
$usedMovieRoleIds[] = $role->getId();
$usedHintKeys[] = $key;
return ['type' => 'character', 'data' => $roleId];
case 'award':
try {
$awards = $this->wikidataAwardGateway->getAwards($mainActor);
} catch (\Throwable) {
return null;
}
foreach ($awards as $award) {
$text = $award['name'] . ' (' . $award['year'] . ')';
$key = 'award:' . $text;
if (!in_array($key, $usedHintKeys)) {
$usedHintKeys[] = $key;
return ['type' => 'award', 'data' => $text];
}
}
return null;
}
return null;
}
```
- [ ] **Step 3: Wire hint generation into `generate()`**
Add `$usedMovieRoleIds = [];` and `$usedHintKeys = [];` after `$rowOrder = 0;` (line 31), and add hint assignment after `$row->setRowOrder($rowOrder);` (line 51):
```php
$hint = $this->generateHint($mainActor, $usedMovieRoleIds, $usedHintKeys);
if ($hint !== null) {
$row->setHintType($hint['type']);
$row->setHintData($hint['data']);
}
```
- [ ] **Step 4: Commit**
```bash
git add src/Service/GameGridGenerator.php
git commit -m "feat: generate hints per row in GameGridGenerator"
```
---
### Task 5: Resolve hint display text in `computeGridData()`
**Files:**
- Modify: `src/Service/GameGridGenerator.php`
- [ ] **Step 1: Add repositories needed for resolution**
Add import:
```php
use App\Repository\MovieRepository;
```
Update constructor:
```php
public function __construct(
private readonly ActorRepository $actorRepository,
private readonly MovieRoleRepository $movieRoleRepository,
private readonly MovieRepository $movieRepository,
private readonly WikidataAwardGateway $wikidataAwardGateway,
private readonly EntityManagerInterface $em,
) {}
```
- [ ] **Step 2: Add hint data to grid output in `computeGridData()`**
In the `computeGridData()` method, update the `$grid[]` assignment (around line 105-109) to include hint data:
```php
$hintText = $this->resolveHintText($row);
$grid[] = [
'actorName' => $actor->getName(),
'actorId' => $actor->getId(),
'pos' => $pos,
'hintType' => $row->getHintType(),
'hintText' => $hintText,
];
```
- [ ] **Step 3: Add `resolveHintText()` method**
```php
private function resolveHintText(GameRow $row): ?string
{
$type = $row->getHintType();
$data = $row->getHintData();
if ($type === null || $data === null) {
return null;
}
return match ($type) {
'film' => $this->movieRepository->find((int) $data)?->getTitle(),
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
'award' => $data,
default => null,
};
}
```
- [ ] **Step 4: Update the return type docblock**
Update the `@return` annotation of `computeGridData()`:
```php
/**
* Compute display data (grid, width, middle) from a Game entity for the React component.
*
* @return array{grid: list<array{actorName: string, actorId: int, pos: int, hintType: ?string, hintText: ?string}>, width: int, middle: int}
*/
```
- [ ] **Step 5: Commit**
```bash
git add src/Service/GameGridGenerator.php src/Repository/MovieRepository.php
git commit -m "feat: resolve hint display text in computeGridData"
```
---
### Task 6: Install Font Awesome and update `ActorPopover` component
**Files:**
- Modify: `assets/react/controllers/ActorPopover.jsx`
- Modify: `assets/react/controllers/GameGrid.jsx`
- Modify: `assets/react/controllers/GameRow.jsx`
- [ ] **Step 1: Install Font Awesome**
```bash
cd /home/thibaud/ltbxd-actorle && npm install @fortawesome/fontawesome-free
```
- [ ] **Step 2: Import Font Awesome CSS in the app entrypoint**
Check the main JS entrypoint:
```bash
head -5 assets/app.js
```
Add at the top of `assets/app.js`:
```js
import '@fortawesome/fontawesome-free/css/all.min.css';
```
- [ ] **Step 3: Update `ActorPopover` to accept hint props**
Replace the full content of `assets/react/controllers/ActorPopover.jsx`:
```jsx
import React, { useState } from 'react';
import { useFloating, useClick, useDismiss, useInteractions, offset, flip, shift } from '@floating-ui/react';
const HINT_ICONS = {
film: 'fa-solid fa-film',
character: 'fa-solid fa-masks-theater',
award: 'fa-solid fa-trophy',
};
export default function ActorPopover({ hintType, hintText }) {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(8), flip(), shift()],
placement: 'left',
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
const iconClass = HINT_ICONS[hintType] || 'fa-solid fa-circle-question';
return (
<>
<button
ref={refs.setReference}
{...getReferenceProps()}
className="popover-trigger"
type="button"
>
<i className={iconClass}></i>
</button>
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="actor-popover"
>
{hintText}
</div>
)}
</>
);
}
```
- [ ] **Step 4: Update `GameRow` to pass hint props**
In `assets/react/controllers/GameRow.jsx`, update the component signature (line 9):
```jsx
export default function GameRow({ actorName, pos, colStart, totalWidth, hintType, hintText }) {
```
Update the `ActorPopover` usage (line 33):
```jsx
<ActorPopover hintType={hintType} hintText={hintText} />
```
- [ ] **Step 5: Update `GameGrid` to pass hint props**
In `assets/react/controllers/GameGrid.jsx`, update the `GameRow` rendering (lines 27-33):
```jsx
<GameRow
key={rowIndex}
actorName={row.actorName}
pos={row.pos}
colStart={middle - row.pos}
totalWidth={width}
hintType={row.hintType}
hintText={row.hintText}
/>
```
- [ ] **Step 6: Commit**
```bash
git add assets/ package.json package-lock.json
git commit -m "feat: replace ? button with hint type icons in ActorPopover"
```
---
### Task 7: Manual end-to-end verification
- [ ] **Step 1: Build frontend assets**
```bash
cd /home/thibaud/ltbxd-actorle && npm run build
```
- [ ] **Step 2: Start a new game and verify**
Open the app in a browser, start a new game. Verify:
- Each row has an icon (film, masks, or trophy) instead of "?"
- Clicking the icon opens a popover with the hint text
- Different rows can have different hint types
- Award hints show the format "Nom du prix (année)"
- [ ] **Step 3: Final commit if any adjustments needed**

View File

@@ -0,0 +1,71 @@
# Game Hints System — Design Spec
## Summary
Each row of the game grid provides a hint about the main actor to guess. Hints are pre-generated when the grid is created and stored on `GameRow`. Each hint has a type (film, character, award) represented by a distinct icon. Clicking the icon opens a popover showing the hint text.
## Data Model
Two new columns on `game_row`:
| Column | Type | Description |
|--------|------|-------------|
| `hint_type` | `VARCHAR(20)`, NOT NULL | One of: `film`, `character`, `award` |
| `hint_data` | `VARCHAR(255)`, NOT NULL | film → `movie.id`, character → `movie_role.id`, award → text "Nom du prix (année)" |
No foreign key constraints on `hint_data` — the column stores either an id (resolved at read time) or raw text depending on `hint_type`.
## Hint Generation
Happens in `GameGridGenerator::generate()`, for each `GameRow`:
1. Pick a random type from `[film, character, award]`
2. Resolve based on type:
- **film**: pick a random `MovieRole` of the main actor → store `movie.id` in `hint_data`
- **character**: pick a random `MovieRole` of the main actor → store `movieRole.id` in `hint_data`
- **award**: call `WikidataAwardGateway` to fetch an award → store `"Nom du prix (année)"` in `hint_data`
3. If the chosen type yields no result (e.g., no awards found), fallback to another random type
4. Avoid duplicate hints across rows (don't show the same film/character/award twice)
## Wikidata Award Gateway
New service: `WikidataAwardGateway`
- Input: actor (name or `tmdb_id`)
- Output: list of awards, each with name and year
- Uses Wikidata SPARQL API to query awards associated with the person
- Storage format in `hint_data`: `"Oscar du meilleur second rôle (2014)"`
- Results can be cached to avoid repeated Wikidata queries
## Frontend
### Icon Button
The current "?" button in `ActorPopover` is replaced by an icon representing the hint type:
| hint_type | Icon | Font Awesome class |
|-----------|------|--------------------|
| `film` | Film/clap | `fa-film` |
| `character` | Theater masks | `fa-masks-theater` |
| `award` | Trophy | `fa-trophy` |
### Popover Content
On click, the popover displays only the hint text:
- **film**: movie title (resolved from `movie.id`)
- **character**: character name (resolved from `movie_role.id`)
- **award**: the raw text from `hint_data`
### Data Flow
1. Backend resolves `hint_data` to display text in `GameGridGenerator::computeGridData()`
2. Hint type + resolved text are passed to the Twig template as part of the grid data
3. Twig passes them as props to the React `GameGrid` component
4. `ActorPopover` receives `hintType` and `hintText` props instead of `actorName`
## Future Extensibility
New hint types can be added by:
1. Adding a new value for `hint_type`
2. Adding resolution logic in `GameGridGenerator`
3. Adding a new icon mapping in the frontend

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260330203017 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE game_row ADD hint_type VARCHAR(20) DEFAULT NULL');
$this->addSql('ALTER TABLE game_row ADD hint_data VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE game_row DROP hint_type');
$this->addSql('ALTER TABLE game_row DROP hint_data');
}
}

10
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"name": "ltbxd-actorle",
"dependencies": {
"@floating-ui/react": "^0.27",
"@fortawesome/fontawesome-free": "^7.2.0",
"@hotwired/stimulus": "^3.2",
"@hotwired/turbo": "^7.3",
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",
@@ -952,6 +953,15 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz",
"integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@hotwired/stimulus": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",

View File

@@ -7,6 +7,7 @@
},
"dependencies": {
"@floating-ui/react": "^0.27",
"@fortawesome/fontawesome-free": "^7.2.0",
"@hotwired/stimulus": "^3.2",
"@hotwired/turbo": "^7.3",
"@symfony/stimulus-bundle": "file:vendor/symfony/stimulus-bundle/assets",

View File

@@ -29,6 +29,12 @@ class GameRow
#[ORM\Column]
private int $rowOrder;
#[ORM\Column(length: 20, nullable: true)]
private ?string $hintType = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $hintData = null;
public function getId(): ?int
{
return $this->id;
@@ -81,4 +87,28 @@ class GameRow
return $this;
}
public function getHintType(): ?string
{
return $this->hintType;
}
public function setHintType(string $hintType): static
{
$this->hintType = $hintType;
return $this;
}
public function getHintData(): ?string
{
return $this->hintData;
}
public function setHintData(string $hintData): static
{
$this->hintData = $hintData;
return $this;
}
}

View File

@@ -16,28 +16,25 @@ class MovieRoleRepository extends ServiceEntityRepository
parent::__construct($registry, MovieRole::class);
}
// /**
// * @return MovieRole[] Returns an array of MovieRole objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
/**
* @param list<int> $excludeMovieRoleIds MovieRole IDs to exclude
* @return MovieRole|null
*/
public function findOneRandomByActor(int $actorId, array $excludeMovieRoleIds = []): ?MovieRole
{
$qb = $this->createQueryBuilder('mr')
->andWhere('mr.actor = :actorId')
->setParameter('actorId', $actorId);
// public function findOneBySomeField($value): ?MovieRole
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
if (!empty($excludeMovieRoleIds)) {
$qb->andWhere('mr.id NOT IN (:excludeIds)')
->setParameter('excludeIds', $excludeMovieRoleIds);
}
return $qb
->orderBy('RANDOM()')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}

View File

@@ -9,12 +9,17 @@ use App\Entity\Game;
use App\Entity\GameRow;
use App\Entity\User;
use App\Repository\ActorRepository;
use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository;
use Doctrine\ORM\EntityManagerInterface;
class GameGridGenerator
{
public function __construct(
private readonly ActorRepository $actorRepository,
private readonly MovieRoleRepository $movieRoleRepository,
private readonly MovieRepository $movieRepository,
private readonly WikidataAwardGateway $wikidataAwardGateway,
private readonly EntityManagerInterface $em,
) {}
@@ -50,6 +55,12 @@ class GameGridGenerator
$row->setPosition(strpos(strtolower($actor->getName()), $char));
$row->setRowOrder($rowOrder);
$hint = $this->generateHint($actor);
if ($hint !== null) {
$row->setHintType($hint['type']);
$row->setHintData($hint['data']);
}
$game->addRow($row);
++$rowOrder;
}
@@ -63,7 +74,7 @@ class GameGridGenerator
/**
* Compute display data (grid, width, middle) from a Game entity for the React component.
*
* @return array{grid: list<array{actorName: string, actorId: int, pos: int}>, width: int, middle: int}
* @return array{grid: list<array{actorName: string, actorId: int, pos: int, hintType: ?string, hintText: ?string}>, width: int, middle: int}
*/
public function computeGridData(Game $game): array
{
@@ -71,7 +82,25 @@ class GameGridGenerator
$rightSize = 0;
$grid = [];
foreach ($game->getRows() as $row) {
$mainActorChars = str_split($game->getMainActor()->getName());
$rows = $game->getRows()->toArray();
$rowIndex = 0;
foreach ($mainActorChars as $char) {
if (!preg_match('/[a-zA-Z]/', $char)) {
$grid[] = [
'separator' => $char,
];
continue;
}
$row = $rows[$rowIndex] ?? null;
++$rowIndex;
if ($row === null) {
continue;
}
$actor = $row->getActor();
$pos = $row->getPosition();
@@ -84,10 +113,14 @@ class GameGridGenerator
$rightSize = $rightSizeActor;
}
$hintText = $this->resolveHintText($row);
$grid[] = [
'actorName' => $actor->getName(),
'actorId' => $actor->getId(),
'pos' => $pos,
'hintType' => $row->getHintType(),
'hintText' => $hintText,
];
}
@@ -97,4 +130,77 @@ class GameGridGenerator
'middle' => $leftSize,
];
}
/**
* Generate a single hint for a row actor.
*
* @return array{type: string, data: string}|null
*/
private function generateHint(Actor $rowActor): ?array
{
$types = ['film', 'character', 'award'];
shuffle($types);
foreach ($types as $type) {
$hint = $this->resolveHint($type, $rowActor);
if ($hint !== null) {
return $hint;
}
}
return null;
}
/**
* @return array{type: string, data: string}|null
*/
private function resolveHint(string $type, Actor $rowActor): ?array
{
switch ($type) {
case 'film':
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
if ($role === null) {
return null;
}
return ['type' => 'film', 'data' => (string) $role->getMovie()->getId()];
case 'character':
$role = $this->movieRoleRepository->findOneRandomByActor($rowActor->getId());
if ($role === null) {
return null;
}
return ['type' => 'character', 'data' => (string) $role->getId()];
case 'award':
try {
$awards = $this->wikidataAwardGateway->getAwards($rowActor);
} catch (\Throwable) {
return null;
}
if (!empty($awards)) {
$award = $awards[array_rand($awards)];
return ['type' => 'award', 'data' => $award['name'] . ' (' . $award['year'] . ')'];
}
return null;
}
return null;
}
private function resolveHintText(GameRow $row): ?string
{
$type = $row->getHintType();
$data = $row->getHintData();
if ($type === null || $data === null) {
return null;
}
return match ($type) {
'film' => $this->movieRepository->find((int) $data)?->getTitle(),
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
'award' => $data,
default => null,
};
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class WikidataAwardGateway
{
private const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql';
public function __construct(
private readonly HttpClientInterface $httpClient,
) {}
/**
* Fetch awards for an actor from Wikidata.
*
* @return list<array{name: string, year: int}>
*/
public function getAwards(Actor $actor): array
{
$sparql = $this->buildQuery($actor->getName());
$response = $this->httpClient->request('GET', self::SPARQL_ENDPOINT, [
'query' => [
'query' => $sparql,
'format' => 'json',
],
'headers' => [
'Accept' => 'application/sparql-results+json',
'User-Agent' => 'LtbxdActorle/1.0',
],
'timeout' => 5,
]);
$data = $response->toArray();
$awards = [];
foreach ($data['results']['bindings'] ?? [] as $binding) {
$name = $binding['awardLabel']['value'] ?? null;
$year = $binding['year']['value'] ?? null;
if ($name && $year) {
$awards[] = [
'name' => $name,
'year' => (int) substr($year, 0, 4),
];
}
}
return $awards;
}
private function buildQuery(string $actorName): string
{
$escaped = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $actorName);
return <<<SPARQL
SELECT ?awardLabel ?year WHERE {
?person rdfs:label "{$escaped}"@en .
?person wdt:P31 wd:Q5 .
?person p:P166 ?awardStatement .
?awardStatement ps:P166 ?award .
?awardStatement pq:P585 ?date .
BIND(YEAR(?date) AS ?year)
SERVICE wikibase:label { bd:serviceParam wikibase:language "fr,en" . }
}
ORDER BY DESC(?year)
SPARQL;
}
}

View File

@@ -7,9 +7,8 @@
<div class="navbar-right">
{# Gitea repo #}
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
<svg width="20" height="20" viewBox="0 0 92 92" fill="currentColor">
<path d="M90.156 41.965 50.036 1.848a5.918 5.918 0 0 0-8.372 0l-8.328 8.332 10.566 10.566a7.03 7.03 0 0 1 7.23 1.684 7.034 7.034 0 0 1 1.669 7.277l10.187 10.184a7.028 7.028 0 0 1 7.278 1.672 7.04 7.04 0 0 1 0 9.957 7.05 7.05 0 0 1-9.965 0 7.044 7.044 0 0 1-1.528-7.66l-9.5-9.497V59.36a7.04 7.04 0 0 1 1.86 11.29 7.04 7.04 0 0 1-9.957 0 7.04 7.04 0 0 1 0-9.958 7.06 7.06 0 0 1 2.304-1.539V33.926a7.049 7.049 0 0 1-3.82-9.234L29.242 14.272 1.73 41.777a5.925 5.925 0 0 0 0 8.371L41.852 90.27a5.925 5.925 0 0 0 8.37 0l39.934-39.934a5.925 5.925 0 0 0 0-8.371"/>
</svg>
</a>
@@ -73,9 +72,8 @@
</div>
<div class="navbar-right">
<a href="https://git.lclr.dev/thibaud-lclr/ltbxd-actorle" class="navbar-icon" target="_blank" rel="noopener noreferrer" title="Code source">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
<svg width="20" height="20" viewBox="0 0 92 92" fill="currentColor">
<path d="M90.156 41.965 50.036 1.848a5.918 5.918 0 0 0-8.372 0l-8.328 8.332 10.566 10.566a7.03 7.03 0 0 1 7.23 1.684 7.034 7.034 0 0 1 1.669 7.277l10.187 10.184a7.028 7.028 0 0 1 7.278 1.672 7.04 7.04 0 0 1 0 9.957 7.05 7.05 0 0 1-9.965 0 7.044 7.044 0 0 1-1.528-7.66l-9.5-9.497V59.36a7.04 7.04 0 0 1 1.86 11.29 7.04 7.04 0 0 1-9.957 0 7.04 7.04 0 0 1 0-9.958 7.06 7.06 0 0 1 2.304-1.539V33.926a7.049 7.049 0 0 1-3.82-9.234L29.242 14.272 1.73 41.777a5.925 5.925 0 0 0 0 8.371L41.852 90.27a5.925 5.925 0 0 0 8.37 0l39.934-39.934a5.925 5.925 0 0 0 0-8.371"/>
</svg>
</a>
<a href="{{ path('app_login') }}" class="btn btn-primary">Se connecter</a>