theme picker, fix the 404 on logout bug :D

38dda48 · gmorell · 2025-09-16 01:58

21 files +524 -75

Files changed

modified apps/room_sanctum/assets/js/app.js
+67 −0
@@ -38,6 +38,73 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
38 38
39 39 let Hooks = {};
40 40
41 +// Theme detection and UI updates
42 +Hooks.ThemeDetector = {
43 + mounted() {
44 + this.updateThemeIndicators();
45 +
46 + // Listen for theme changes
47 + window.addEventListener("phx:set-theme", () => {
48 + setTimeout(() => this.updateThemeIndicators(), 100);
49 + });
50 +
51 + // Listen for storage changes (cross-tab)
52 + window.addEventListener("storage", (e) => {
53 + if (e.key === "apollos:theme") {
54 + setTimeout(() => this.updateThemeIndicators(), 100);
55 + }
56 + });
57 +
58 + // Listen for system theme changes
59 + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
60 + if (!localStorage.getItem("apollos:theme")) {
61 + setTimeout(() => this.updateThemeIndicators(), 100);
62 + }
63 + });
64 + },
65 +
66 + updateThemeIndicators() {
67 + const currentTheme = this.getCurrentTheme();
68 + const themeCards = this.el.querySelectorAll('.theme-card');
69 +
70 + themeCards.forEach(card => {
71 + const themeName = card.getAttribute('data-theme-name');
72 + const indicator = card.querySelector('.theme-indicator');
73 +
74 + if (themeName === currentTheme) {
75 + // Show as current theme
76 + card.classList.add('border-blue-500', 'ring-2', 'ring-blue-500');
77 + card.classList.remove('border-gray-200', 'dark:border-gray-700');
78 + indicator.classList.remove('hidden');
79 + } else {
80 + // Show as selectable theme
81 + card.classList.remove('border-blue-500', 'ring-2', 'ring-blue-500');
82 + card.classList.add('border-gray-200', 'dark:border-gray-700');
83 + indicator.classList.add('hidden');
84 + }
85 + });
86 + },
87 +
88 + getCurrentTheme() {
89 + const savedTheme = localStorage.getItem("apollos:theme");
90 + if (savedTheme) {
91 + return savedTheme;
92 + }
93 +
94 + // Check if system theme is being used
95 + const currentDataTheme = document.documentElement.getAttribute("data-theme");
96 + if (!savedTheme && currentDataTheme) {
97 + // This means system theme is active, determine what it resolved to
98 + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
99 + if (currentDataTheme === (isDark ? 'dark' : 'lofi')) {
100 + return 'system';
101 + }
102 + }
103 +
104 + return currentDataTheme || 'system';
105 + }
106 +};
107 +
41 108 // Add our new LeafletMap hook
42 109 Hooks.LeafletMap = LeafletMap;
43 110 Hooks.MapPush = {
modified apps/room_sanctum/assets/js/hooks/leaflet_map.js
+47 −3
@@ -371,6 +371,21 @@ const LeafletMap = {
371 371 const popupContent = this.createPopupContent(query);
372 372 marker.bindPopup(popupContent);
373 373
374 + // Add event listener for the duplicate query button after popup opens
375 + marker.on('popupopen', (e) => {
376 + const popup = e.popup;
377 + const duplicateQueryBtn = popup.getElement().querySelector('.duplicate-query-btn');
378 + if (duplicateQueryBtn) {
379 + duplicateQueryBtn.addEventListener('click', () => {
380 + this.pushEvent('add-query-from-map', {
381 + station_id: query.id.toString(),
382 + name: query.name,
383 + type: 'query'
384 + });
385 + });
386 + }
387 + });
388 +
374 389 // Add click handler for query navigation
375 390 marker.on('click', () => {
376 391 // You can emit events back to LiveView if needed
@@ -391,6 +406,21 @@ const LeafletMap = {
391 406 const popupContent = this.createVehiclePopupContent(vehicle);
392 407 marker.bindPopup(popupContent);
393 408
409 + // Add event listener for vehicle query button after popup opens (if it's a GTFS vehicle)
410 + marker.on('popupopen', (e) => {
411 + const popup = e.popup;
412 + const addVehicleQueryBtn = popup.getElement().querySelector('.add-vehicle-query-btn');
413 + if (addVehicleQueryBtn) {
414 + addVehicleQueryBtn.addEventListener('click', () => {
415 + this.pushEvent('add-query-from-map', {
416 + station_id: vehicle.vehicle_id,
417 + name: `Vehicle ${vehicle.vehicle_id} Position Query`,
418 + type: 'vehicle'
419 + });
420 + });
421 + }
422 + });
423 +
394 424 return marker;
395 425 },
396 426
@@ -535,10 +565,22 @@ const LeafletMap = {
535 565 </div>
536 566 ` : ''}
537 567
538 − <div class="text-xs text-gray-500">
568 + <div class="text-xs text-gray-500 mb-2">
539 569 <i class="fa-solid fa-location-dot mr-1"></i>
540 570 ${vehicle.lat.toFixed(4)}, ${vehicle.lng.toFixed(4)}
541 571 </div>
572 +
573 + <div class="flex justify-center mt-2">
574 + <button
575 + class="add-vehicle-query-btn btn btn-sm btn-primary text-xs px-3 py-1 rounded-md bg-orange-600 hover:bg-orange-700 text-white transition-colors"
576 + data-vehicle-id="${vehicle.vehicle_id}"
577 + data-query-type="vehicle"
578 + title="Create a vehicle position query for this vehicle"
579 + >
580 + <i class="fa-solid fa-plus mr-1"></i>
581 + Add Vehicle Query
582 + </button>
583 + </div>
542 584 </div>
543 585 `;
544 586 },
@@ -850,8 +892,10 @@ const LeafletMap = {
850 892
851 893 <div class="flex justify-center mt-2">
852 894 <button
853 − class="btn btn-sm btn-primary text-xs px-3 py-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors"
854 − onclick="window.liveSocket.execJS(this, [['push', {event: 'add-query-from-map', value: {station_id: '${query.id}', name: '${query.name.replace(/'/g, "\\'")}', type: 'query'}}]])"
895 + class="duplicate-query-btn btn btn-sm btn-primary text-xs px-3 py-1 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors"
896 + data-query-id="${query.id}"
897 + data-query-name="${query.name.replace(/"/g, '&quot;')}"
898 + data-query-type="query"
855 899 >
856 900 <i class="fa-solid fa-plus mr-1"></i>
857 901 Duplicate Query
modified apps/room_sanctum/assets/tailwind.config.js
+45 −0
@@ -29,8 +29,53 @@ module.exports = {
29 29 "warning": "#fef08a",
30 30 "error": "#fca5a5",
31 31 },
32 + forest: {
33 + "primary": "#4ade80",
34 + "secondary": "#86efac",
35 + "accent": "#fef3c7",
36 + "neutral": "#166534",
37 + "base-100": "#052e16",
38 + "info": "#7dd3fc",
39 + "success": "#a7f3d0",
40 + "warning": "#fef08a",
41 + "error": "#fca5a5",
42 + },
43 + sky: {
44 + "primary": "#38bdf8",
45 + "secondary": "#7dd3fc",
46 + "accent": "#fef3c7",
47 + "neutral": "#0c4a6e",
48 + "base-100": "#082f49",
49 + "info": "#7dd3fc",
50 + "success": "#a7f3d0",
51 + "warning": "#fef08a",
52 + "error": "#fca5a5",
53 + },
54 + clays: {
55 + "primary": "#d97706",
56 + "secondary": "#f59e0b",
57 + "accent": "#fef3c7",
58 + "neutral": "#92400e",
59 + "base-100": "#451a03",
60 + "info": "#7dd3fc",
61 + "success": "#a7f3d0",
62 + "warning": "#fef08a",
63 + "error": "#fca5a5",
64 + },
65 + stones: {
66 + "primary": "#6b7280",
67 + "secondary": "#9ca3af",
68 + "accent": "#fef3c7",
69 + "neutral": "#57534e",
70 + "base-100": "#292524",
71 + "info": "#7dd3fc",
72 + "success": "#a7f3d0",
73 + "warning": "#fef08a",
74 + "error": "#fca5a5",
75 + },
32 76 },
33 77 "lofi",
78 + "black"
34 79 ]
35 80 },
36 81 content: [
modified apps/room_sanctum/lib/room_sanctum_web/components/layouts/root.html.heex
+42 −1
@@ -1,5 +1,5 @@
1 1 <!DOCTYPE html>
2 −<html lang="en" data-theme="her">
2 +<html lang="en">
3 3 <head>
4 4 <meta charset="utf-8"/>
5 5 <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
@@ -10,6 +10,47 @@
10 10 "/assets/app.css")}/>
11 11 <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn,
12 12 "/assets/app.js")}></script>
13 + <script>
14 + (() => {
15 + const setTheme = (theme) => {
16 + if (theme === "system") {
17 + localStorage.removeItem("apollos:theme");
18 + // Remove theme attribute to let system preference take over
19 + document.documentElement.removeAttribute("data-theme");
20 + // Apply system theme based on media query
21 + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'lofi';
22 + document.documentElement.setAttribute("data-theme", systemTheme);
23 + } else {
24 + localStorage.setItem("apollos:theme", theme);
25 + document.documentElement.setAttribute("data-theme", theme);
26 + }
27 + };
28 +
29 + // Apply saved theme or system preference on page load
30 + if (!document.documentElement.hasAttribute("data-theme")) {
31 + setTheme(localStorage.getItem("apollos:theme") || "system");
32 + }
33 +
34 + // Listen for cross-tab theme changes
35 + window.addEventListener("storage", (e) => {
36 + if (e.key === "apollos:theme") {
37 + setTheme(e.newValue || "system");
38 + }
39 + });
40 +
41 + // Listen for theme change events from components
42 + window.addEventListener("phx:set-theme", (e) => {
43 + setTheme(e.target.dataset.phxTheme || e.detail.theme);
44 + });
45 +
46 + // Listen for system theme changes
47 + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
48 + if (!localStorage.getItem("apollos:theme")) {
49 + setTheme("system");
50 + }
51 + });
52 + })();
53 + </script>
13 54 </head>
14 55 <body>
15 56 <div class="p-2">
modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+2 −2
@@ -115,7 +115,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
115 115 has_query_point = extract_from_query_data(query) != false
116 116
117 117 result = has_geom || has_source_point || has_query_point
118 − IO.inspect({query.id, query.name, has_geom, has_source_point, has_query_point, result}, label: "Query mappability check")
118 +# IO.inspect({query.id, query.name, has_geom, has_source_point, has_query_point, result}, label: "Query mappability check")
119 119 result
120 120 end)
121 121
@@ -150,7 +150,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
150 150 {lat, lng} = extract_coordinates(query)
151 151
152 152 # Debug log the coordinate extraction
153 − IO.inspect({query.id, query.name, lat, lng}, label: "Map coordinates for query")
153 +# IO.inspect({query.id, query.name, lat, lng}, label: "Map coordinates for query")
154 154
155 155 tint = cond do
156 156 query.meta && query.meta.tint -> query.meta.tint
added apps/room_sanctum/lib/room_sanctum_web/components/theme_picker.ex
+122 −0
@@ -0,0 +1,122 @@
1 +defmodule RoomSanctumWeb.ThemePicker do
2 + use Phoenix.Component
3 + alias Phoenix.LiveView.JS
4 +
5 + @themes [
6 + %{name: "system", title: "System", description: "Follow system preference",
7 + colors: %{primary: "#6b7280", secondary: "#9ca3af", accent: "#fef3c7", neutral: "#374151", base: "#f9fafb"}},
8 + %{name: "afterdark", title: "After Dark", description: "Dark purple theme",
9 + colors: %{primary: "#7B79B5", secondary: "#ACABD5", accent: "#fef3c7", neutral: "#38357F", base: "#201D65"}},
10 + %{name: "black", title: "Black", description: "True black theme",
11 + colors: %{primary: "#373737", secondary: "#a6adbb", accent: "#66cc8a", neutral: "#2a2e37", base: "#1d232a"}},
12 + %{name: "clays", title: "Clays", description: "Warm earth tones",
13 + colors: %{primary: "#d97706", secondary: "#f59e0b", accent: "#fef3c7", neutral: "#92400e", base: "#451a03"}},
14 + %{name: "forest", title: "Forest", description: "Green nature theme",
15 + colors: %{primary: "#4ade80", secondary: "#86efac", accent: "#fef3c7", neutral: "#166534", base: "#052e16"}},
16 + %{name: "her", title: "Her", description: "Orange/warm theme",
17 + colors: %{primary: "#b57979", secondary: "#d5abab", accent: "#fef3c7", neutral: "#7f3535", base: "#651d1d"}},
18 + %{name: "lofi", title: "Lo-Fi", description: "Neutral minimalist",
19 + colors: %{primary: "#0f0f23", secondary: "#a6adbb", accent: "#66cc8a", neutral: "#2a2e37", base: "#1d232a"}},
20 + %{name: "sky", title: "Sky", description: "Blue sky theme",
21 + colors: %{primary: "#38bdf8", secondary: "#7dd3fc", accent: "#fef3c7", neutral: "#0c4a6e", base: "#082f49"}},
22 + %{name: "stones", title: "Stones", description: "Gray/stone theme",
23 + colors: %{primary: "#6b7280", secondary: "#9ca3af", accent: "#fef3c7", neutral: "#57534e", base: "#292524"}}
24 + ]
25 +
26 + @doc """
27 + Renders a theme picker with preview cards for each theme.
28 +
29 + The current theme is determined client-side and doesn't need to be passed as an attribute.
30 + The component will automatically detect which theme is currently active.
31 +
32 + ## Examples
33 +
34 + <.theme_picker />
35 + """
36 + attr :class, :string, default: nil
37 + attr :id, :string, default: "theme-picker"
38 +
39 + def theme_picker(assigns) do
40 + themes = [
41 + %{name: "system", title: "System", description: "Follow system preference",
42 + colors: %{primary: "#6b7280", secondary: "#9ca3af", accent: "#fef3c7", neutral: "#374151", base: "#f9fafb"}},
43 + %{name: "afterdark", title: "After Dark", description: "Dark purple theme",
44 + colors: %{primary: "#7B79B5", secondary: "#ACABD5", accent: "#fef3c7", neutral: "#38357F", base: "#201D65"}},
45 + %{name: "black", title: "Black", description: "True black theme",
46 + colors: %{primary: "#373737", secondary: "#a6adbb", accent: "#66cc8a", neutral: "#2a2e37", base: "#1d232a"}},
47 + %{name: "clays", title: "Clays", description: "Warm earth tones",
48 + colors: %{primary: "#d97706", secondary: "#f59e0b", accent: "#fef3c7", neutral: "#92400e", base: "#451a03"}},
49 + %{name: "forest", title: "Forest", description: "Green nature theme",
50 + colors: %{primary: "#4ade80", secondary: "#86efac", accent: "#fef3c7", neutral: "#166534", base: "#052e16"}},
51 + %{name: "her", title: "Her", description: "Orange/warm theme",
52 + colors: %{primary: "#b57979", secondary: "#d5abab", accent: "#fef3c7", neutral: "#7f3535", base: "#651d1d"}},
53 + %{name: "lofi", title: "Lo-Fi", description: "Neutral minimalist",
54 + colors: %{primary: "#0f0f23", secondary: "#a6adbb", accent: "#66cc8a", neutral: "#2a2e37", base: "#1d232a"}},
55 + %{name: "sky", title: "Sky", description: "Blue sky theme",
56 + colors: %{primary: "#38bdf8", secondary: "#7dd3fc", accent: "#fef3c7", neutral: "#0c4a6e", base: "#082f49"}},
57 + %{name: "stones", title: "Stones", description: "Gray/stone theme",
58 + colors: %{primary: "#6b7280", secondary: "#9ca3af", accent: "#fef3c7", neutral: "#57534e", base: "#292524"}}
59 + ]
60 +
61 + assigns = assign(assigns, :themes, themes)
62 +
63 + ~H"""
64 + <div class={["theme-picker", @class]} id={@id} phx-hook="ThemeDetector">
65 + <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">
66 + Choose Theme
67 + </h3>
68 +
69 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
70 + <.theme_card
71 + :for={theme <- @themes}
72 + theme={theme}
73 + />
74 + </div>
75 + </div>
76 + """
77 + end
78 +
79 + @doc """
80 + Renders a single theme preview card.
81 + """
82 + attr :theme, :map, required: true
83 +
84 + def theme_card(assigns) do
85 + ~H"""
86 + <div class="theme-card relative rounded-lg border-2 cursor-pointer transition-all hover:scale-105 border-gray-200 dark:border-gray-700 hover:border-gray-300 overflow-hidden"
87 + data-theme-name={@theme.name}>
88 +
89 + <!-- Theme indicator -->
90 + <div class="absolute top-2 right-2 theme-indicator hidden z-10">
91 + <div class="w-3 h-3 bg-blue-500 rounded-full shadow-lg"></div>
92 + </div>
93 +
94 + <!-- Theme preview with name overlay -->
95 + <button
96 + type="button"
97 + phx-click={JS.dispatch("phx:set-theme")}
98 + data-phx-theme={@theme.name}
99 + class="w-full h-24 flex items-center justify-center text-center transition-all hover:brightness-110"
100 + style={
101 + if @theme.name == "system" do
102 + "background: linear-gradient(135deg, #{@theme.colors.base} 0%, #{@theme.colors.primary} 100%); color: #{@theme.colors.neutral};"
103 + else
104 + "background-color: #{@theme.colors.base}; color: #{if String.starts_with?(@theme.colors.base, "#0") or String.starts_with?(@theme.colors.base, "#1") or String.starts_with?(@theme.colors.base, "#2"), do: @theme.colors.accent, else: @theme.colors.neutral};"
105 + end
106 + }
107 + >
108 + <%= if @theme.name == "system" do %>
109 + <div class="flex flex-col items-center">
110 + <svg class="w-6 h-6 mb-1 opacity-80" fill="currentColor" viewBox="0 0 20 20">
111 + <path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
112 + </svg>
113 + <span class="text-sm font-medium">{@theme.title}</span>
114 + </div>
115 + <% else %>
116 + <span class="text-sm font-medium">{@theme.title}</span>
117 + <% end %>
118 + </button>
119 + </div>
120 + """
121 + end
122 +end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/index.ex
+1 −1
@@ -105,7 +105,7 @@ defmodule RoomSanctumWeb.SourceLive.Index do
105 105 end
106 106
107 107 def handle_event("set-tint", %{"tint"=> tint}, socket) do
108 − IO.inspect({"set-tint", tint, socket.assigns.tint})
108 +# IO.inspect({"set-tint", tint, socket.assigns.tint})
109 109 case socket.assigns.tint == tint do
110 110 true -> {:noreply, socket |> assign(:tint, nil) |> stream(:cfg_sources, list_cfg_sources(socket.assigns.current_user.id), reset: true)}
111 111 false -> {:noreply, socket |> assign(:tint, tint) |> stream(:cfg_sources, list_cfg_sources(socket.assigns.current_user.id, tint), reset: true)}
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+13 −2
@@ -167,7 +167,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
167 167
168 168 @impl true
169 169 def handle_event("do-stats", %{"type" => type, "id" => id}, socket) do
170 − IO.inspect({type, id})
170 +# IO.inspect({type, id})
171 171
172 172 stats =
173 173 case type do
@@ -278,6 +278,17 @@ defmodule RoomSanctumWeb.SourceLive.Show do
278 278 public: true
279 279 }) |> IO.inspect
280 280
281 + {:gtfs, "vehicle"} ->
282 + # For GTFS vehicle position queries, create a vehicle tracking query
283 + Configuration.create_query(
284 + %{
285 + user_id: socket.assigns.current_user.id,
286 + source_id: socket.assigns.source.id,
287 + name: "#{name}",
288 + query: %{"vehicle_id" => station_id, "__type__" => "gtfs_vehicle_positions"},
289 + public: true
290 + }) |> IO.inspect
291 +
281 292 {_, "query"} ->
282 293 # For duplicating existing queries, find the original and copy it
283 294 original_query = Enum.find(socket.assigns.queries, &(&1.id == String.to_integer(station_id)))
@@ -330,7 +341,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
330 341 end
331 342
332 343 def handle_event("set-tint", %{"tint"=> tint}, socket) do
333 − IO.inspect({"set-tint", tint, socket.assigns.tint})
344 +# IO.inspect({"set-tint", tint, socket.assigns.tint})
334 345 case socket.assigns.tint == tint do
335 346 true -> {:noreply, socket |> assign(:tint, nil)}
336 347 false -> {:noreply, socket |> assign(:tint, tint)}
modified apps/room_sanctum/lib/room_sanctum_web/live/live_auth.ex
+1 −1
@@ -12,7 +12,7 @@ defmodule RoomSanctum.UserLiveAuth do
12 12 )
13 13
14 14 if socket.assigns.current_user == nil do
15 − {:halt, redirect(socket, to: "/users/login")}
15 + {:halt, redirect(socket, to: "/users/log_in")}
16 16 else
17 17 {:cont, socket}
18 18 end
modified apps/room_sanctum/lib/room_sanctum_web/live/user_settings_live.ex
+184 −65
@@ -2,78 +2,182 @@ defmodule RoomSanctumWeb.UserSettingsLive do
2 2 use RoomSanctumWeb, :live_view
3 3
4 4 alias RoomSanctum.Accounts
5 + alias RoomSanctumWeb.ThemePicker
5 6
6 7 def render(assigns) do
7 8 ~H"""
8 − <.header class="text-center">
9 − Account Settings
10 − <:subtitle>Manage your account email address and password settings</:subtitle>
11 − </.header>
12 −
13 − <div class="space-y-12 divide-y">
14 − <div>
15 − <.simple_form
16 − for={@email_form}
17 − id="email_form"
18 − phx-submit="update_email"
19 − phx-change="validate_email"
20 − >
21 − <.input field={@email_form[:email]} type="email" label="Email" required />
22 − <.input
23 − field={@email_form[:current_password]}
24 − name="current_password"
25 − id="current_password_for_email"
26 − type="password"
27 − label="Current password"
28 − value={@email_form_current_password}
29 − required
30 − />
31 − <:actions>
32 − <.button phx-disable-with="Changing...">Change Email</.button>
33 − </:actions>
34 − </.simple_form>
35 − </div>
36 − <div>
37 − <.simple_form
38 − for={@password_form}
39 − id="password_form"
40 − action={~p"/users/log_in?_action=password_updated"}
41 − method="post"
42 − phx-change="validate_password"
43 − phx-submit="update_password"
44 − phx-trigger-action={@trigger_submit}
45 − >
46 − <.input
47 − field={@password_form[:email]}
48 − type="hidden"
49 − id="hidden_user_email"
50 − value={@current_email}
51 − />
52 − <.input field={@password_form[:password]} type="password" label="New password" required />
53 − <.input
54 − field={@password_form[:password_confirmation]}
55 − type="password"
56 − label="Confirm new password"
57 − />
58 − <.input
59 − field={@password_form[:current_password]}
60 − name="current_password"
61 − type="password"
62 − label="Current password"
63 − id="current_password_for_password"
64 − value={@current_password}
65 − required
66 − />
67 − <:actions>
68 − <.button phx-disable-with="Changing...">Change Password</.button>
69 − </:actions>
70 − </.simple_form>
9 + <div class="hero min-h-screen bg-base-200">
10 + <div class="hero-content w-full max-w-4xl">
11 + <div class="w-full">
12 + <div class="text-center mb-8">
13 + <h1 class="text-4xl font-bold text-base-content">Account Settings</h1>
14 + <p class="text-base-content/70 mt-2">Manage your account preferences and security</p>
15 + </div>
16 +
17 + <div class="space-y-8">
18 + <!-- Theme Picker Section -->
19 + <div class="card bg-base-100 shadow-xl">
20 + <div class="card-body">
21 + <h2 class="card-title text-primary mb-4">
22 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
23 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5H9M21 9H9M21 13H9M21 17H9"></path>
24 + </svg>
25 + Theme Preferences
26 + </h2>
27 + <ThemePicker.theme_picker />
28 + </div>
29 + </div>
30 +
31 + <!-- Email Settings -->
32 + <div class="card bg-base-100 shadow-xl">
33 + <div class="card-body">
34 + <h2 class="card-title text-primary mb-4">
35 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
36 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"></path>
37 + </svg>
38 + Email Address
39 + </h2>
40 + <p class="text-base-content/70 mb-4">Update your email address. You'll need to verify the new email address.</p>
41 +
42 + <.simple_form
43 + for={@email_form}
44 + id="email_form"
45 + phx-submit="update_email"
46 + phx-change="validate_email"
47 + class="space-y-4"
48 + >
49 + <div class="form-control w-full">
50 + <label class="label">
51 + <span class="label-text font-medium">Email Address</span>
52 + </label>
53 + <.input
54 + field={@email_form[:email]}
55 + type="email"
56 + required
57 + class="input input-bordered w-full focus:input-primary"
58 + placeholder="Enter your email address"
59 + />
60 + </div>
61 +
62 + <div class="form-control w-full">
63 + <label class="label">
64 + <span class="label-text font-medium">Current Password</span>
65 + <span class="label-text-alt text-base-content/50">Required for security</span>
66 + </label>
67 + <.input
68 + field={@email_form[:current_password]}
69 + name="current_password"
70 + id="current_password_for_email"
71 + type="password"
72 + value={@email_form_current_password}
73 + required
74 + class="input input-bordered w-full focus:input-primary"
75 + placeholder="Enter your current password"
76 + />
77 + </div>
78 +
79 + <div class="card-actions justify-end">
80 + <.button phx-disable-with="Updating..." class="btn btn-primary">
81 + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
82 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
83 + </svg>
84 + Update Email
85 + </.button>
86 + </div>
87 + </.simple_form>
88 + </div>
89 + </div>
90 +
91 + <!-- Password Settings -->
92 + <div class="card bg-base-100 shadow-xl">
93 + <div class="card-body">
94 + <h2 class="card-title text-primary mb-4">
95 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
96 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
97 + </svg>
98 + Password Security
99 + </h2>
100 + <p class="text-base-content/70 mb-4">Change your password to keep your account secure.</p>
101 +
102 + <.simple_form
103 + for={@password_form}
104 + id="password_form"
105 + action={~p"/users/log_in?_action=password_updated"}
106 + method="post"
107 + phx-change="validate_password"
108 + phx-submit="update_password"
109 + phx-trigger-action={@trigger_submit}
110 + class="space-y-4"
111 + >
112 + <.input
113 + field={@password_form[:email]}
114 + type="hidden"
115 + id="hidden_user_email"
116 + value={@current_email}
117 + />
118 +
119 + <div class="form-control w-full">
120 + <label class="label">
121 + <span class="label-text font-medium">New Password</span>
122 + <span class="label-text-alt text-base-content/50">At least 8 characters</span>
123 + </label>
124 + <.input
125 + field={@password_form[:password]}
126 + type="password"
127 + required
128 + class="input input-bordered w-full focus:input-primary"
129 + placeholder="Enter your new password"
130 + />
131 + </div>
132 +
133 + <div class="form-control w-full">
134 + <label class="label">
135 + <span class="label-text font-medium">Confirm New Password</span>
136 + </label>
137 + <.input
138 + field={@password_form[:password_confirmation]}
139 + type="password"
140 + class="input input-bordered w-full focus:input-primary"
141 + placeholder="Confirm your new password"
142 + />
143 + </div>
144 +
145 + <div class="form-control w-full">
146 + <label class="label">
147 + <span class="label-text font-medium">Current Password</span>
148 + <span class="label-text-alt text-base-content/50">Required for security</span>
149 + </label>
150 + <.input
151 + field={@password_form[:current_password]}
152 + name="current_password"
153 + type="password"
154 + id="current_password_for_password"
155 + value={@current_password}
156 + required
157 + class="input input-bordered w-full focus:input-primary"
158 + placeholder="Enter your current password"
159 + />
160 + </div>
161 +
162 + <div class="card-actions justify-end">
163 + <.button phx-disable-with="Updating..." class="btn btn-primary">
164 + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
165 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
166 + </svg>
167 + Change Password
168 + </.button>
169 + </div>
170 + </.simple_form>
171 + </div>
172 + </div>
173 + </div>
174 + </div>
71 175 </div>
72 176 </div>
73 177 """
74 178 end
75 179
76 − def mount(%{"token" => token}, _session, socket) do
180 + def mount(%{"token" => token}, session, socket) do
77 181 socket =
78 182 case Accounts.update_user_email(socket.assigns.current_user, token) do
79 183 :ok ->
@@ -86,10 +190,11 @@ defmodule RoomSanctumWeb.UserSettingsLive do
86 190 {:ok, push_navigate(socket, to: ~p"/users/settings")}
87 191 end
88 192
89 − def mount(_params, _session, socket) do
193 + def mount(_params, session, socket) do
90 194 user = socket.assigns.current_user
91 195 email_changeset = Accounts.change_user_email(user)
92 196 password_changeset = Accounts.change_user_password(user)
197 + current_theme = get_current_theme(session)
93 198
94 199 socket =
95 200 socket
@@ -99,10 +204,15 @@ defmodule RoomSanctumWeb.UserSettingsLive do
99 204 |> assign(:email_form, to_form(email_changeset))
100 205 |> assign(:password_form, to_form(password_changeset))
101 206 |> assign(:trigger_submit, false)
207 + |> assign(:current_theme, current_theme)
102 208
103 209 {:ok, socket}
104 210 end
105 211
212 + defp get_current_theme(session) do
213 + Map.get(session, "theme", "lofi")
214 + end
215 +
106 216 def handle_event("validate_email", params, socket) do
107 217 %{"current_password" => password, "user" => user_params} = params
108 218
@@ -164,4 +274,13 @@ defmodule RoomSanctumWeb.UserSettingsLive do
164 274 {:noreply, assign(socket, password_form: to_form(changeset))}
165 275 end
166 276 end
277 +
278 + def handle_event("change_theme", %{"theme" => theme}, socket) do
279 + socket =
280 + socket
281 + |> assign(:current_theme, theme)
282 + |> put_flash(:info, "Theme updated successfully!")
283 +
284 + {:noreply, socket}
285 + end
167 286 end

Parents: c32f492