feat(story-1.5): theme selection and persistence

- SpaController: injects data-theme on <html> and window.__PF_USER__ before JS
  hydrates — theme applied without FOUC; no initial API call needed for user data
- UserApiController: PATCH /api/user/theme validates against 6 allowed theme IDs,
  persists to user.theme column, returns {theme}
- useTheme composable: applyTheme() sets html[data-theme], saveTheme() calls API
  and falls back with toast on error
- SettingsView: 3-col theme grid with swatch previews, aria-checked radio semantics,
  active indicator; Sign out link; signed-in email display
- App.vue: onMounted syncs Pinia theme state with SpaController-stamped html[data-theme]

Verified: data-theme injected on / load; PATCH saves to DB; reload shows persisted theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 00:37:59 -04:00
parent 3c1d5f0eae
commit 15bab87998
5 changed files with 297 additions and 7 deletions
+21 -4
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
@@ -32,9 +33,25 @@ class SpaController extends AbstractController
throw $this->createNotFoundException('SPA not built — run npm run build in frontend/.');
}
return new Response(
content: (string) file_get_contents($indexFile),
headers: ['Content-Type' => 'text/html; charset=utf-8'],
);
/** @var User $user */
$user = $this->getUser();
$theme = $user->getTheme() ?? 'warm-craft';
$userData = json_encode([
'id' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'theme' => $theme,
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
$html = (string) file_get_contents($indexFile);
// Inject theme on <html> so CSS applies before JS hydrates (no FOUC)
$html = str_replace('<html lang="en">', '<html lang="en" data-theme="' . htmlspecialchars($theme, ENT_QUOTES) . '">', $html);
// Bootstrap current user into window so Pinia auth store needs no initial API call
$html = str_replace('</head>', '<script>window.__PF_USER__=' . $userData . ';</script></head>', $html);
return new Response($html, headers: ['Content-Type' => 'text/html; charset=utf-8']);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/user')]
#[IsGranted('ROLE_USER')]
class UserApiController extends AbstractController
{
private const VALID_THEMES = [
'warm-craft',
'playful-pop',
'sage-cream',
'dusty-mauve',
'ocean-dusk',
'honey-slate',
];
#[Route('/theme', name: 'api_user_theme', methods: ['PATCH'])]
public function updateTheme(Request $request, EntityManagerInterface $em): JsonResponse
{
$body = json_decode($request->getContent(), true);
$theme = $body['theme'] ?? null;
if (!is_string($theme) || !in_array($theme, self::VALID_THEMES, true)) {
return $this->json(['error' => 'Invalid theme'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var User $user */
$user = $this->getUser();
$user->setTheme($theme);
$em->flush();
return $this->json(['theme' => $theme]);
}
}