Private
Public Access
1
0

new: usr: Add opportunity to use profile picture. #4

This commit is contained in:
2026-04-13 15:50:28 +02:00
parent 98f6e8cb6e
commit 0c0b8ae920
23 changed files with 1952 additions and 212 deletions

View File

@@ -13,7 +13,14 @@ namespace App\Controller;
use App\Entity\User;
use App\Repository\PlayedGameRepository;
use App\Service\WebAuthnService;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
@@ -34,8 +41,7 @@ class ProfileController extends AbstractController
public function __construct(
private readonly PlayedGameRepository $repo,
private readonly WebAuthnService $webAuthnService
)
{
) {
}
#[Route('/profile', name: 'MineSeekerBundle_profile')]
@@ -45,22 +51,22 @@ class ProfileController extends AbstractController
$user = $this->getUser();
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->repo->countFinishedForUser($user);
$wins = $this->repo->countWinsForUser($user);
$losses = $this->repo->countLossesForUser($user);
$draws = $this->repo->countDrawsForUser($user);
$total = $this->repo->countFinishedForUser($user);
$wins = $this->repo->countWinsForUser($user);
$losses = $this->repo->countLossesForUser($user);
$draws = $this->repo->countDrawsForUser($user);
// Build monthly buckets for the last 6 months
$monthlyData = [];
for ($i = 5; $i >= 0; $i--) {
$dt = new \DateTime("first day of -$i months midnight");
$dt = new \DateTime("first day of -$i months midnight");
$key = $dt->format('Y-m');
$monthlyData[$key] = ['label' => $dt->format('M'), 'wins' => 0, 'losses' => 0, 'draws' => 0];
}
$since = new \DateTime('first day of -5 months midnight');
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
$userId = $user->getId();
$since = new \DateTime('first day of -5 months midnight');
$recentGames = $this->repo->findFinishedForUserSince($user, $since);
$userId = $user->getId();
foreach ($recentGames as $game) {
if (!$game->getUpdated()) {
@@ -72,12 +78,12 @@ class ProfileController extends AbstractController
continue;
}
$isRed = $game->getRed()?->getId() === $userId;
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
$resign = $game->getResign();
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$isRed = $game->getRed()?->getId() === $userId;
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
$resign = $game->getResign();
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$result = 'draws';
if ($resign === $myColor) {
@@ -85,8 +91,8 @@ class ProfileController extends AbstractController
} elseif ($resign === $oppColor) {
$result = 'wins';
} elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'wins';
elseif ($myPts < $oppPts) $result = 'losses';
if ($myPts > $oppPts) $result = 'wins';
elseif ($myPts < $oppPts) $result = 'losses';
}
$monthlyData[$month][$result]++;
@@ -95,58 +101,60 @@ class ProfileController extends AbstractController
$months = array_column(array_values($monthlyData), 'label');
return $this->render('Security/profile.html.twig', [
'stats' => [
'stats' => [
'total' => $total,
'wins' => $wins,
'losses' => $losses,
'draws' => $draws,
'bombs' => $this->repo->countBombsForUser($user),
'winRate' => $total > 0 ? (int) round($wins / $total * 100) : 0,
'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0,
'avgScore' => $this->repo->findAvgScoreForUser($user),
'bestScore' => $this->repo->findBestScoreForUser($user),
],
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
'gamesData' => array_map(static function (\App\Entity\PlayedGame $game) use ($userId): array {
$isRed = $game->getRed()?->getId() === $userId;
$resign = $game->getResign();
$isRed = $game->getRed()?->getId() === $userId;
$resign = $game->getResign();
$myColor = $isRed ? 'red' : 'blue';
$oppColor = $isRed ? 'blue' : 'red';
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
$myPts = $isRed ? $game->getRedPoints() : $game->getBluePoints();
$oppPts = $isRed ? $game->getBluePoints() : $game->getRedPoints();
$result = 'draw';
if ($resign === $myColor) $result = 'loss';
elseif ($resign === $oppColor) $result = 'win';
if ($resign === $myColor) $result = 'loss';
elseif ($resign === $oppColor) $result = 'win';
elseif ($myPts !== null && $oppPts !== null) {
if ($myPts > $oppPts) $result = 'win';
elseif ($myPts < $oppPts) $result = 'loss';
if ($myPts > $oppPts) $result = 'win';
elseif ($myPts < $oppPts) $result = 'loss';
}
return [
'id' => $game->getId(),
'redName' => $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
'blueName' => $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
'redPoints' => $game->getRedPoints(),
'bluePoints' => $game->getBluePoints(),
'redExplodedBomb' => $game->getRedExplodedBomb(),
'blueExplodedBomb' => $game->getBlueExplodedBomb(),
'resign' => $resign,
'created' => $game->getCreated()?->format('Y-m-d H:i'),
'date' => $game->getUpdated()?->format('Y-m-d H:i'),
'isRed' => $isRed,
'result' => $result,
'myPoints' => $myPts,
'oppPoints' => $oppPts,
'id' => $game->getId(),
'redName' =>
$game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest',
'blueName' =>
$game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest',
'redPoints' => $game->getRedPoints(),
'bluePoints' => $game->getBluePoints(),
'redExplodedBomb' => $game->getRedExplodedBomb(),
'blueExplodedBomb' => $game->getBlueExplodedBomb(),
'resign' => $resign,
'created' => $game->getCreated()?->format('Y-m-d H:i'),
'date' => $game->getUpdated()?->format('Y-m-d H:i'),
'isRed' => $isRed,
'result' => $result,
'myPoints' => $myPts,
'oppPoints' => $oppPts,
];
}, $recent),
'chartData' => [
'months' => $months,
'wins' => array_column(array_values($monthlyData), 'wins'),
'losses' => array_column(array_values($monthlyData), 'losses'),
'draws' => array_column(array_values($monthlyData), 'draws'),
'pieWins' => $wins,
'pieLosses' => $losses,
'pieDraws' => $draws,
'months' => $months,
'wins' => array_column(array_values($monthlyData), 'wins'),
'losses' => array_column(array_values($monthlyData), 'losses'),
'draws' => array_column(array_values($monthlyData), 'draws'),
'pieWins' => $wins,
'pieLosses' => $losses,
'pieDraws' => $draws,
],
]);
}
@@ -159,33 +167,91 @@ class ProfileController extends AbstractController
throw $this->createNotFoundException('Battle not found.');
}
$redName = $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest';
$redName = $game->getRed()?->getUsername() ?? $game->getRedAnon()?->getUserName() ?? 'Guest';
$blueName = $game->getBlue()?->getUsername() ?? $game->getBlueAnon()?->getUserName() ?? 'Guest';
$redPts = $game->getRedPoints();
$bluePts = $game->getBluePoints();
$resign = $game->getResign();
$redPts = $game->getRedPoints();
$bluePts = $game->getBluePoints();
$resign = $game->getResign();
if ($resign === 'red') {
$summary = "$redName resigned — $blueName wins";
} elseif ($resign === 'blue') {
$summary = "$blueName resigned — $redName wins";
} elseif ($redPts !== null && $bluePts !== null) {
if ($redPts > $bluePts) $summary = "$redName defeated $blueName ($redPts $bluePts)";
elseif ($bluePts > $redPts) $summary = "$blueName defeated $redName ($bluePts $redPts)";
else $summary = "$redName and $blueName drew ($redPts $bluePts)";
if ($redPts > $bluePts) {
$summary = "$redName defeated $blueName ($redPts $bluePts)";
} elseif ($bluePts > $redPts) {
$summary = "$blueName defeated $redName ($bluePts $redPts)";
} else {
$summary = "$redName and $blueName drew ($redPts $bluePts)";
}
} else {
$summary = "$redName vs $blueName";
}
return $this->render('Game/battle_share.html.twig', [
'game' => $game,
'redName' => $redName,
'blueName' => $blueName,
'redPts' => $redPts,
'bluePts' => $bluePts,
'resign' => $resign,
'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
'game' => $game,
'redName' => $redName,
'blueName' => $blueName,
'redPts' => $redPts,
'bluePts' => $bluePts,
'resign' => $resign,
'ogTitle' => "MineSeeker · $summary",
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
]);
}
#[Route('/profile/avatar', name: 'MineSeekerBundle_profile_avatar', methods: ['POST'])]
public function uploadAvatar(
Request $request,
EntityManagerInterface $em,
CacheManager $cacheManager,
#[Autowire(service: 'mineseeker.media.storage')] FilesystemOperator $mediaStorage,
): JsonResponse {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
/** @var User $user */
$user = $this->getUser();
$file = $request->files->get('avatar');
if (!$file instanceof UploadedFile) {
return $this->json(['error' => 'No file uploaded.'], 400);
}
if ($file->getSize() > 2 * 1024 * 1024) {
return $this->json(['error' => 'File is too large. Maximum 2 MB.'], 400);
}
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($file->getMimeType(), $allowed, true)) {
return $this->json(['error' => 'Invalid type. Allowed: JPEG, PNG, GIF, WEBP.'], 400);
}
$ext = $file->guessExtension() ?? 'jpg';
$newPath = sprintf('avatar/%d.%s', $user->getId(), $ext);
$oldPath = $user->getAvatarPath();
// Remove old file and any cached thumbnails
if ($oldPath) {
if ($oldPath !== $newPath) {
try {
$mediaStorage->delete($oldPath);
} catch (\Throwable) {
}
}
$cacheManager->remove($oldPath, 'avatar_thumb');
}
// Upload original to MinIO media/avatar/
$stream = fopen($file->getPathname(), 'r');
$mediaStorage->writeStream($newPath, $stream);
fclose($stream);
$user->setAvatarPath($newPath);
$em->flush();
return $this->json([
'thumbUrl' => $cacheManager->generateUrl($newPath, 'avatar_thumb'),
]);
}

View File

@@ -75,6 +75,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
#[Column(type: Types::JSON, nullable: true)]
private ?array $backupCodes = [];
#[Column(length: 255, nullable: true)]
private ?string $avatarPath = null;
public function getId(): ?int
{
@@ -180,7 +183,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
return $this;
}
// --- TotpTwoFactorInterface ---
public function getAvatarPath(): ?string
{
return $this->avatarPath;
}
public function setAvatarPath(?string $avatarPath): self
{
$this->avatarPath = $avatarPath;
return $this;
}
public function isTotpAuthenticationEnabled(): bool
{
@@ -211,8 +223,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
return $this;
}
// --- BackupCodeInterface ---
public function isBackupCode(string $code): bool
{
return \in_array($code, $this->backupCodes ?? [], true);

View File

@@ -0,0 +1,42 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260413000001
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 13.
*/
final class Version20260413000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add avatar_path to app_user for MinIO profile picture storage';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ADD avatar_path VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user DROP avatar_path');
}
}