Skip to content

Commit 6fa1486

Browse files
dotMavriQclaude
andcommitted
feat: add Games category enhancements and Board Games scaffolding
- Add PlayingStatus enum (backlog/playing/shelved/completed/mastered) - Add OwnershipStatus: borrowed, on_emulator options - Convert game genre to JSON array for proper filtering - Add platform logos (NES, N64, PS2, GBC, GB) with CSS mask-image - Add platformMeta() and shortPlatformName() helpers - Make external IDs (IGDB, RAWG, MobyGames) clickable links - Add genre filter with whereJsonContains on game index - Fix NULLS LAST sorting for nullable columns - Scaffold Board Games category with BGG XML API2 integration - Add BoardGame model, migration, policy, Livewire components - Add BggService with Saloon connector for search/details - Add Board Games to Playing hub, navigation, and routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ef9329 commit 6fa1486

50 files changed

Lines changed: 2688 additions & 67 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/Enums/OwnershipStatus.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ enum OwnershipStatus: string
88
{
99
case Owned = 'owned';
1010
case PreviouslyOwned = 'previously_owned';
11+
case Borrowed = 'borrowed';
12+
case OnEmulator = 'on_emulator';
1113
case NotOwned = 'not_owned';
1214

1315
public function label(): string
1416
{
1517
return match ($this) {
1618
self::Owned => 'Owned',
1719
self::PreviouslyOwned => 'Previously Owned',
20+
self::Borrowed => 'Borrowed',
21+
self::OnEmulator => 'On Emulator',
1822
self::NotOwned => 'Not Owned',
1923
};
2024
}
@@ -24,6 +28,8 @@ public function color(): string
2428
return match ($this) {
2529
self::Owned => 'green',
2630
self::PreviouslyOwned => 'yellow',
31+
self::Borrowed => 'blue',
32+
self::OnEmulator => 'purple',
2733
self::NotOwned => 'gray',
2834
};
2935
}

app/Enums/PlayingStatus.php

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,31 @@
66

77
enum PlayingStatus: string
88
{
9-
case WantToPlay = 'want_to_play';
9+
case Backlog = 'backlog';
1010
case Playing = 'playing';
11-
case Played = 'played';
11+
case Shelved = 'shelved';
12+
case Completed = 'completed';
13+
case Mastered = 'mastered';
1214

1315
public function label(): string
1416
{
1517
return match ($this) {
16-
self::WantToPlay => 'Want to Play',
18+
self::Backlog => 'Backlog',
1719
self::Playing => 'Playing',
18-
self::Played => 'Played',
20+
self::Shelved => 'Shelved',
21+
self::Completed => 'Completed',
22+
self::Mastered => 'Mastered',
1923
};
2024
}
2125

2226
public function color(): string
2327
{
2428
return match ($this) {
25-
self::WantToPlay => 'purple',
29+
self::Backlog => 'gray',
2630
self::Playing => 'yellow',
27-
self::Played => 'green',
31+
self::Shelved => 'orange',
32+
self::Completed => 'green',
33+
self::Mastered => 'purple',
2834
};
2935
}
3036
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Livewire\BoardGames;
6+
7+
use App\Enums\OwnershipStatus;
8+
use App\Enums\PlayingStatus;
9+
use App\Models\BoardGame;
10+
use App\Services\BggService;
11+
use Illuminate\Support\Facades\Auth;
12+
use Livewire\Component;
13+
14+
class BoardGameBggSearch extends Component
15+
{
16+
public string $step = 'search';
17+
18+
public string $searchQuery = '';
19+
20+
public array $results = [];
21+
22+
public ?array $selectedGame = null;
23+
24+
public string $status = 'backlog';
25+
26+
public string $ownership = 'owned';
27+
28+
public ?int $rating = null;
29+
30+
public string $notes = '';
31+
32+
public function search(): void
33+
{
34+
if (trim($this->searchQuery) === '') {
35+
return;
36+
}
37+
38+
$bgg = app(BggService::class);
39+
$this->results = $bgg->search($this->searchQuery);
40+
$this->step = 'results';
41+
}
42+
43+
public function selectGame(int $bggId): void
44+
{
45+
$bgg = app(BggService::class);
46+
$details = $bgg->getDetails($bggId);
47+
48+
if (! $details) {
49+
session()->flash('error', 'Could not fetch board game details.');
50+
return;
51+
}
52+
53+
$this->selectedGame = $details;
54+
$this->step = 'configure';
55+
}
56+
57+
public function save(): void
58+
{
59+
if (! $this->selectedGame) {
60+
return;
61+
}
62+
63+
$boardGame = BoardGame::create([
64+
'user_id' => Auth::id(),
65+
'title' => $this->selectedGame['title'],
66+
'genre' => $this->selectedGame['genres'] ?? [],
67+
'description' => $this->selectedGame['description'] ?? null,
68+
'cover_url' => $this->selectedGame['cover_url'] ?? null,
69+
'year_published' => $this->selectedGame['year_published'] ?? null,
70+
'designer' => $this->selectedGame['designer'] ?? null,
71+
'publisher' => $this->selectedGame['publisher'] ?? null,
72+
'min_players' => $this->selectedGame['min_players'] ?? null,
73+
'max_players' => $this->selectedGame['max_players'] ?? null,
74+
'playing_time' => $this->selectedGame['playing_time'] ?? null,
75+
'status' => $this->status,
76+
'ownership' => $this->ownership,
77+
'rating' => $this->rating,
78+
'bgg_id' => $this->selectedGame['bgg_id'],
79+
'notes' => $this->notes ?: null,
80+
]);
81+
82+
session()->flash('message', "{$boardGame->title} added to your collection!");
83+
$this->redirect(route('board-games.show', $boardGame));
84+
}
85+
86+
public function backToResults(): void
87+
{
88+
$this->selectedGame = null;
89+
$this->step = 'results';
90+
}
91+
92+
public function backToSearch(): void
93+
{
94+
$this->results = [];
95+
$this->step = 'search';
96+
}
97+
98+
public function render()
99+
{
100+
return view('livewire.board-games.board-game-bgg-search', [
101+
'statuses' => PlayingStatus::cases(),
102+
'ownershipStatuses' => OwnershipStatus::cases(),
103+
])->layout('layouts.app');
104+
}
105+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Livewire\BoardGames;
6+
7+
use App\Enums\OwnershipStatus;
8+
use App\Enums\PlayingStatus;
9+
use App\Models\BoardGame;
10+
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
11+
use Illuminate\Support\Facades\Auth;
12+
use Illuminate\Validation\Rule;
13+
use Livewire\Component;
14+
15+
class BoardGameForm extends Component
16+
{
17+
use AuthorizesRequests;
18+
19+
public ?BoardGame $boardGame = null;
20+
21+
public string $title = '';
22+
23+
public array $genre = [];
24+
25+
public string $genreInput = '';
26+
27+
public string $description = '';
28+
29+
public string $cover_url = '';
30+
31+
public ?int $year_published = null;
32+
33+
public string $designer = '';
34+
35+
public string $publisher = '';
36+
37+
public ?int $min_players = null;
38+
39+
public ?int $max_players = null;
40+
41+
public ?int $playing_time = null;
42+
43+
public string $status = 'backlog';
44+
45+
public string $ownership = 'owned';
46+
47+
public ?int $rating = null;
48+
49+
public ?int $plays = null;
50+
51+
public ?int $bgg_id = null;
52+
53+
public ?string $date_started = null;
54+
55+
public ?string $date_finished = null;
56+
57+
public string $notes = '';
58+
59+
public function mount(?BoardGame $boardGame = null): void
60+
{
61+
if ($boardGame && $boardGame->exists) {
62+
$this->authorize('update', $boardGame);
63+
$this->boardGame = $boardGame;
64+
$this->fill([
65+
'title' => $boardGame->title,
66+
'genre' => $boardGame->genre ?? [],
67+
'description' => $boardGame->description ?? '',
68+
'cover_url' => $boardGame->cover_url ?? '',
69+
'year_published' => $boardGame->year_published,
70+
'designer' => $boardGame->designer ?? '',
71+
'publisher' => $boardGame->publisher ?? '',
72+
'min_players' => $boardGame->min_players,
73+
'max_players' => $boardGame->max_players,
74+
'playing_time' => $boardGame->playing_time,
75+
'status' => $boardGame->status->value,
76+
'ownership' => $boardGame->ownership->value,
77+
'rating' => $boardGame->rating,
78+
'plays' => $boardGame->plays,
79+
'bgg_id' => $boardGame->bgg_id,
80+
'date_started' => $boardGame->date_started?->format('d/m/Y'),
81+
'date_finished' => $boardGame->date_finished?->format('d/m/Y'),
82+
'notes' => $boardGame->notes ?? '',
83+
]);
84+
}
85+
}
86+
87+
public function rules(): array
88+
{
89+
return [
90+
'title' => ['required', 'string', 'max:255'],
91+
'genre' => ['nullable', 'array'],
92+
'genre.*' => ['string', 'max:100'],
93+
'description' => ['nullable', 'string', 'max:10000'],
94+
'cover_url' => ['nullable', 'url', 'max:2048'],
95+
'year_published' => ['nullable', 'integer', 'min:1900', 'max:2100'],
96+
'designer' => ['nullable', 'string', 'max:255'],
97+
'publisher' => ['nullable', 'string', 'max:255'],
98+
'min_players' => ['nullable', 'integer', 'min:1', 'max:100'],
99+
'max_players' => ['nullable', 'integer', 'min:1', 'max:100'],
100+
'playing_time' => ['nullable', 'integer', 'min:0', 'max:9999'],
101+
'status' => ['required', Rule::enum(PlayingStatus::class)],
102+
'ownership' => ['required', Rule::enum(OwnershipStatus::class)],
103+
'rating' => ['nullable', 'integer', 'min:1', 'max:10'],
104+
'plays' => ['nullable', 'integer', 'min:0'],
105+
'bgg_id' => ['nullable', 'integer'],
106+
'date_started' => ['nullable', 'date_format:d/m/Y'],
107+
'date_finished' => ['nullable', 'date_format:d/m/Y'],
108+
'notes' => ['nullable', 'string', 'max:10000'],
109+
];
110+
}
111+
112+
protected function parseDateInput(?string $date): ?string
113+
{
114+
if (empty($date)) {
115+
return null;
116+
}
117+
118+
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
119+
return $date;
120+
}
121+
122+
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $date, $matches)) {
123+
return checkdate((int) $matches[2], (int) $matches[1], (int) $matches[3])
124+
? "{$matches[3]}-{$matches[2]}-{$matches[1]}"
125+
: null;
126+
}
127+
128+
return null;
129+
}
130+
131+
public function addGenre(): void
132+
{
133+
$genre = trim($this->genreInput);
134+
if ($genre !== '' && ! in_array($genre, $this->genre)) {
135+
$this->genre[] = $genre;
136+
}
137+
$this->genreInput = '';
138+
}
139+
140+
public function removeGenre(int $index): void
141+
{
142+
unset($this->genre[$index]);
143+
$this->genre = array_values($this->genre);
144+
}
145+
146+
public function save(): void
147+
{
148+
$validated = $this->validate();
149+
150+
$validated['date_started'] = $this->parseDateInput($validated['date_started'] ?? null);
151+
$validated['date_finished'] = $this->parseDateInput($validated['date_finished'] ?? null);
152+
153+
$data = [
154+
'title' => $validated['title'],
155+
'genre' => ! empty($validated['genre']) ? $validated['genre'] : null,
156+
'description' => $validated['description'] ?: null,
157+
'cover_url' => $validated['cover_url'] ?: null,
158+
'year_published' => $validated['year_published'],
159+
'designer' => $validated['designer'] ?: null,
160+
'publisher' => $validated['publisher'] ?: null,
161+
'min_players' => $validated['min_players'],
162+
'max_players' => $validated['max_players'],
163+
'playing_time' => $validated['playing_time'],
164+
'status' => $validated['status'],
165+
'ownership' => $validated['ownership'],
166+
'rating' => $validated['rating'],
167+
'plays' => $validated['plays'],
168+
'bgg_id' => $validated['bgg_id'],
169+
'date_started' => $validated['date_started'],
170+
'date_finished' => $validated['date_finished'],
171+
'notes' => $validated['notes'] ?: null,
172+
];
173+
174+
if ($this->boardGame) {
175+
$this->boardGame->update($data);
176+
$message = 'Board game updated successfully.';
177+
} else {
178+
$data['user_id'] = Auth::id();
179+
$this->boardGame = BoardGame::create($data);
180+
$message = 'Board game created successfully.';
181+
}
182+
183+
session()->flash('message', $message);
184+
185+
$this->redirect(route('board-games.show', $this->boardGame));
186+
}
187+
188+
public function isEditing(): bool
189+
{
190+
return $this->boardGame !== null && $this->boardGame->exists;
191+
}
192+
193+
public function render()
194+
{
195+
return view('livewire.board-games.board-game-form', [
196+
'statuses' => PlayingStatus::cases(),
197+
'ownershipStatuses' => OwnershipStatus::cases(),
198+
'isEditing' => $this->isEditing(),
199+
])->layout('layouts.app');
200+
}
201+
}

0 commit comments

Comments
 (0)