diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8dba32a..1900f22 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,6 +5,22 @@ diff --git a/frontend/src/composables/useTheme.ts b/frontend/src/composables/useTheme.ts new file mode 100644 index 0000000..ae3b05a --- /dev/null +++ b/frontend/src/composables/useTheme.ts @@ -0,0 +1,45 @@ +import { useAuthStore } from '@/stores/auth' +import { useToastStore } from '@/stores/toast' + +export interface ThemeOption { + id: string + label: string + primary: string + bg: string + text: string +} + +export const THEMES: ThemeOption[] = [ + { id: 'warm-craft', label: 'Warm Craft', primary: '#c97c3a', bg: '#fdf6ee', text: '#3a2e22' }, + { id: 'playful-pop', label: 'Playful Pop', primary: '#d63aab', bg: '#fff0fb', text: '#2d0a28' }, + { id: 'sage-cream', label: 'Sage & Cream', primary: '#4e7c3a', bg: '#f6f8f3', text: '#1e2b1a' }, + { id: 'dusty-mauve', label: 'Dusty Mauve', primary: '#8e4a84', bg: '#f6f0f4', text: '#2a1828' }, + { id: 'ocean-dusk', label: 'Ocean Dusk', primary: '#1a6ea8', bg: '#eef3f8', text: '#0e2030' }, + { id: 'honey-slate', label: 'Honey & Slate', primary: '#c49a20', bg: '#f2f2ee', text: '#1c1c18' }, +] + +export function useTheme() { + const auth = useAuthStore() + const toast = useToastStore() + + function applyTheme(themeId: string) { + document.documentElement.dataset.theme = themeId + if (auth.user) auth.user.theme = themeId + } + + async function saveTheme(themeId: string) { + applyTheme(themeId) + try { + const res = await fetch('/api/user/theme', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ theme: themeId }), + }) + if (!res.ok) throw new Error('Failed to save theme') + } catch { + toast.show('Could not save theme — try again', 'error') + } + } + + return { THEMES, applyTheme, saveTheme } +} diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 29ee79d..d2cd4ee 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1,9 +1,175 @@ + + diff --git a/src/Controller/SpaController.php b/src/Controller/SpaController.php index f169957..740273f 100644 --- a/src/Controller/SpaController.php +++ b/src/Controller/SpaController.php @@ -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 so CSS applies before JS hydrates (no FOUC) + $html = str_replace('', '', $html); + + // Bootstrap current user into window so Pinia auth store needs no initial API call + $html = str_replace('', '', $html); + + return new Response($html, headers: ['Content-Type' => 'text/html; charset=utf-8']); } } diff --git a/src/Controller/UserApiController.php b/src/Controller/UserApiController.php new file mode 100644 index 0000000..0939474 --- /dev/null +++ b/src/Controller/UserApiController.php @@ -0,0 +1,46 @@ +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]); + } +}