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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user