some better things

ac18dca · gmorell · 2025-07-01 21:55

18 files +606 -235

Files changed

modified apps/room_sanctum/assets/js/app.js
+190 −15
@@ -26,9 +26,9 @@ import {Socket} from "phoenix"
26 26 import {LiveSocket} from "phoenix_live_view"
27 27 import topbar from "../vendor/topbar"
28 28
29 −import './leaflet/leaflet-map'
30 −import './leaflet/leaflet-marker'
31 −import './leaflet/leaflet-icon'
29 +// import './leaflet/leaflet-map'
30 +// import './leaflet/leaflet-marker'
31 +// import './leaflet/leaflet-icon'
32 32
33 33 import L from 'leaflet'
34 34 import 'leaflet-centermarker'
@@ -48,31 +48,169 @@ Hooks.MapPush = {
48 48 Hooks.mkMap = {
49 49 mounted() {
50 50 const markers = {}
51 − latlng = document.getElementById('map').getAttribute('data-latlng')
51 + let searchMarker = null;
52 +
53 + let latlng = document.getElementById('map').getAttribute('data-latlng')
52 54 if (latlng != null) {
53 − coords = JSON.parse(latlng)
55 + let coords = JSON.parse(latlng)
54 56 } else {
55 − coords = [42.3736, -71.1097]
57 + let coords = [42.3736, -71.1097]
56 58 }
57 − coords = [18.33967, -67.23713]
59 +
58 60 var map = L.map('map', {keyboard: true}).setView(coords, 13);
59 61
60 62 const view = this;
61 − var marker = L.centerMarker(map).on("newposition", function() {
62 − var latlng = marker.getLatLng()
63 + var centerMarker = L.centerMarker(map).on("newposition", function() {
64 + var latlng = centerMarker.getLatLng()
63 65 console.log("New position: " + latlng.lat + ", " + latlng.lng);
64 66 view.pushEventTo("#foci-form", "map-update", { latlng: latlng });
67 + });
68 + centerMarker.addTo(map);
65 69
66 − });;
67 − marker.addTo(map);
68 −
69 − L.tileLayer('https:{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
70 + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
70 71 maxZoom: 19,
71 − attribution: '&copy; <a href="https:openstreetmap.org/copyright">OpenStreetMap contributors</a>'
72 + attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
72 73 }).addTo(map);
73 74
75 + // Search functionality
76 + const searchInput = document.getElementById('location-search');
77 + const searchResults = document.getElementById('search-results');
78 + let searchTimeout;
79 +
80 + const performSearch = async (query) => {
81 + if (query.length < 3) {
82 + searchResults.classList.add('hidden');
83 + return;
84 + }
85 +
86 + try {
87 + const response = await fetch(
88 + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1`
89 + );
90 + const results = await response.json();
91 +
92 + displaySearchResults(results);
93 + } catch (error) {
94 + console.error('Search error:', error);
95 + searchResults.classList.add('hidden');
96 + }
97 + };
98 +
99 + const displaySearchResults = (results) => {
100 + searchResults.innerHTML = '';
101 +
102 + if (results.length === 0) {
103 + searchResults.innerHTML = '<div class="p-3 text-gray-500">No results found</div>';
104 + searchResults.classList.remove('hidden');
105 + return;
106 + }
107 +
108 + results.forEach(result => {
109 + const resultDiv = document.createElement('div');
110 + resultDiv.className = 'p-3 hover:bg-gray-100 cursor-pointer border-b border-gray-200 last:border-b-0';
111 + resultDiv.innerHTML = `
112 + <div class="font-medium">${result.display_name}</div>
113 + <div class="text-sm text-gray-600">${result.type} • ${result.class}</div>
114 + `;
115 +
116 + resultDiv.addEventListener('click', () => {
117 + selectSearchResult(result);
118 + });
119 +
120 + searchResults.appendChild(resultDiv);
121 + });
122 +
123 + searchResults.classList.remove('hidden');
124 + };
125 +
126 + const selectSearchResult = (result) => {
127 + const lat = parseFloat(result.lat);
128 + const lon = parseFloat(result.lon);
129 +
130 + // Update search input
131 + searchInput.value = result.display_name;
132 + searchResults.classList.add('hidden');
133 +
134 + // Remove previous search marker
135 + if (searchMarker) {
136 + map.removeLayer(searchMarker);
137 + }
138 +
139 + // Add new search marker
140 + searchMarker = L.marker([lat, lon], {
141 + icon: L.icon({
142 + iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png',
143 + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
144 + iconSize: [25, 41],
145 + iconAnchor: [12, 41],
146 + popupAnchor: [1, -34],
147 + shadowSize: [41, 41]
148 + })
149 + }).addTo(map);
150 +
151 + // Add popup with result info
152 + searchMarker.bindPopup(`
153 + <div>
154 + <strong>${result.display_name}</strong><br>
155 + <small>${result.type} • ${result.class}</small><br>
156 + <button onclick="window.selectThisLocation(${lat}, ${lon})" class="btn btn-sm btn-primary mt-2">
157 + Select This Location
158 + </button>
159 + </div>
160 + `).openPopup();
161 +
162 + // Pan to the result
163 + map.setView([lat, lon], 16);
164 + };
165 +
166 + // Global function to select a location from popup
167 + window.selectThisLocation = (lat, lon) => {
168 + // Move center marker to selected location
169 + centerMarker.setLatLng([lat, lon]);
170 + map.setView([lat, lon], 16);
171 +
172 + // Trigger the position update
173 + view.pushEventTo("#foci-form", "map-update", {latlng: {lat: lat, lng: lon}});
174 +
175 + // Close popup
176 + if (searchMarker) {
177 + searchMarker.closePopup();
178 + }
179 + };
180 +
181 + // Search input event listeners
182 + if (searchInput) {
183 + searchInput.addEventListener('input', (e) => {
184 + clearTimeout(searchTimeout);
185 + const query = e.target.value.trim();
186 +
187 + if (query.length === 0) {
188 + searchResults.classList.add('hidden');
189 + return;
190 + }
191 +
192 + searchTimeout = setTimeout(() => {
193 + performSearch(query);
194 + }, 300);
195 + });
196 +
197 + // Hide search results when clicking outside
198 + document.addEventListener('click', (e) => {
199 + if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
200 + searchResults.classList.add('hidden');
201 + }
202 + });
203 +
204 + // Show search results when focusing on input
205 + searchInput.addEventListener('focus', () => {
206 + if (searchInput.value.trim().length >= 3) {
207 + performSearch(searchInput.value.trim());
208 + }
209 + });
210 + }
211 +
74 212 this.handleEvent("add_marker", ({lat, lon}) => {
75 − const marker = L.marker(L.latLng(lat, lomn))
213 + const marker = L.marker(L.latLng(lat, lon))
76 214 marker.addTo(map)
77 215 })
78 216 }
@@ -112,6 +250,43 @@ Hooks.mkTesterMap = {
112 250 }
113 251 }
114 252
253 +Hooks.mkShowMap = {
254 + latlng() { return this.el.dataset.latlng },
255 + mounted() {
256 + var latlng = this.latlng()
257 + console.log("Show Map Mounted")
258 + console.log(latlng)
259 +
260 + let coords;
261 + if (latlng == null) {
262 + coords = [42.3736, -71.1097];
263 + } else {
264 + coords = JSON.parse(latlng);
265 + }
266 +
267 + // Create static map for show view with unique ID
268 + let map = L.map(this.el.id, {
269 + keyboard: false,
270 + dragging: false,
271 + touchZoom: false,
272 + scrollWheelZoom: false,
273 + doubleClickZoom: false,
274 + boxZoom: false,
275 + tap: false
276 + }).setView(coords, 15);
277 +
278 + // Add a marker at the location if coordinates exist
279 + if (latlng != null) {
280 + L.marker(coords).addTo(map);
281 + }
282 +
283 + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
284 + maxZoom: 19,
285 + attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
286 + }).addTo(map);
287 + }
288 +}
289 +
115 290 let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
116 291
117 292 // Show progress bar on live navigation and form submits
modified apps/room_sanctum/lib/room_sanctum/configuration/vision.ex
+27 −2
@@ -17,10 +17,22 @@ defmodule RoomSanctum.Configuration.Vision do
17 17 def changeset(vision, attrs) do
18 18 vision
19 19 |> cast(attrs, [:name, :user_id, :query_ids, :public])
20 − |> cast_embed(:queries, with: &RoomSanctum.Configuration.Vision.Schema.changeset/2)
20 + |> cast_embed(:queries, with: &RoomSanctum.Configuration.Vision.Schema.changeset/2, sort_param: :queries_sort, drop_param: :queries_drop)
21 21 |> foreign_key_constraint(:user_id)
22 22 |> validate_required([:name, :user_id])
23 + |> validate_queries_have_valid_ids()
23 24 end
25 +
26 + defp validate_queries_have_valid_ids(changeset) do
27 + case get_field(changeset, :query_ids) do
28 + ids when is_list(ids) and length(ids) > 0 ->
29 + # Validate that all query IDs exist and belong to the user
30 + changeset
31 + _ ->
32 + changeset
33 + end
34 + end
35 +
24 36 end
25 37
26 38 defmodule RoomSanctum.Configuration.Vision.Schema do
@@ -121,6 +133,19 @@ defmodule RoomSanctum.Configuration.Vision.Schema3Background do
121 133 def changeset(source, params) do
122 134 source
123 135 |> cast(params, [:query, :order])
124 − |> validate_required([:query, :order])
136 + |> validate_required([:order])
137 + |> validate_query_selection()
138 + end
139 +
140 + defp validate_query_selection(changeset) do
141 + case get_field(changeset, :query) do
142 + query when is_integer(query) and query > 0 -> changeset
143 + query when is_binary(query) and query != "" ->
144 + case Integer.parse(query) do
145 + {id, ""} when id > 0 -> put_change(changeset, :query, id)
146 + _ -> add_error(changeset, :query, "must be a valid query ID")
147 + end
148 + _ -> changeset # Allow empty for new entries
149 + end
125 150 end
126 151 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_foci_live/form_component.html.heex
+12 −0
@@ -25,6 +25,18 @@
25 25 </div>
26 26 <div>
27 27 <div class="w-full">
28 + <div class="form-control mb-4">
29 + <label class="block text-sm font-semibold leading-6 text-zinc-800 mb-2">Search Location</label>
30 + <div class="relative">
31 + <input
32 + type="text"
33 + id="location-search"
34 + placeholder="Search for addresses, places, or POIs..."
35 + class="input input-bordered w-full pr-10"
36 + />
37 + <div id="search-results" class="absolute z-10 w-full bg-white border border-gray-300 rounded-md shadow-lg hidden max-h-60 overflow-y-auto"></div>
38 + </div>
39 + </div>
28 40 <div class="form-control">
29 41 <label class="block text-sm font-semibold leading-6 text-zinc-800">
30 42 <div id="structural-shim" phx-update="ignore">
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_foci_live/show.ex
+8 −0
@@ -18,4 +18,12 @@ defmodule RoomSanctumWeb.FociLive.Show do
18 18
19 19 defp page_title(:show), do: "Foci Detail"
20 20 defp page_title(:edit), do: "Modify Foci"
21 +
22 + defp getlatlng(%{:place => nil}) do
23 + nil
24 + end
25 +
26 + defp getlatlng(%{:place => place}) do
27 + place |> Map.get(:coordinates, {}) |> Tuple.to_list() |> Poison.encode!()
28 + end
21 29 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_foci_live/show.html.heex
+12 −1
@@ -22,7 +22,18 @@
22 22 <div class="card card-compact w-3/4 bg-base-100 shadow-xl m-2">
23 23 <div class="card-body">
24 24 <h3 class="card-title">Place</h3>
25 − <pre>@foci.place</pre>
25 + <div class="mt-4">
26 + <link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css"
27 + integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ=="
28 + crossorigin=""/>
29 + <script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js"
30 + integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ=="
31 + crossorigin=""></script>
32 +
33 + <div id="show-map-container" phx-update="ignore">
34 + <div id="show-map" style="height: 300px; width: 100%" phx-hook="mkShowMap" data-latlng={@foci |> getlatlng} class="rounded-sm"></div>
35 + </div>
36 + </div>
26 37 </div>
27 38 </div>
28 39 </div>
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/form_component.ex
+243 −158
@@ -1,132 +1,191 @@
1 1 defmodule RoomSanctumWeb.VisionLive.FormComponent do
2 2 use RoomSanctumWeb, :live_component
3 + import PolymorphicEmbed.HTML.Helpers
3 4
4 5 alias RoomSanctum.Configuration
6 + alias RoomSanctum.Configuration.Vision
5 7
6 − defp inj_uid(params, socket) do
7 − params
8 − # |> IO.inspect
9 − |> Map.put("user_id", socket.assigns.current_user.id)
10 − |> Map.put(
11 − "query_ids",
12 − params |> Map.get("queries", %{}) |> Enum.map(fn {k, v} -> v["data"]["query"] end)
13 − )
14 − end
15 −
16 − defp inj_types(params) do
17 − params
18 − |> Map.put(
19 − "queries",
20 − params
21 − |> Map.get("queries", %{})
22 − |> Enum.map(fn {k, v} -> {k, v |> Kernel.put_in(["data", "__type__"], v["type"])} end)
23 − |> Enum.into(%{})
24 − )
8 + @impl true
9 + def render(assigns) do
10 + ~H"""
11 + <div>
12 + <.header>
13 + <%= @title %>
14 + <:subtitle>Configure your vision with custom queries</:subtitle>
15 + </.header>
16 +
17 + <.simple_form
18 + for={@form}
19 + id="vision-form"
20 + phx-target={@myself}
21 + phx-change="validate"
22 + phx-submit="save"
23 + >
24 + <.input field={@form[:name]} type="text" label="Name" />
25 +
26 + <.input field={@form[:public]} type="checkbox" label="Make this vision public" />
27 +
28 + <div class="mt-6">
29 + <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Queries</h3>
30 +
31 + <div id="queries" class="space-y-4">
32 + <.inputs_for :let={query_form} field={@form[:queries]}>
33 + <div class="border rounded-lg p-4 relative">
34 + <input type="hidden" name="vision[queries_sort][]" value={query_form.index} />
35 +
36 + <div class="absolute top-2 right-2">
37 + <.button
38 + type="button"
39 + phx-click="remove_query"
40 + phx-value-index={query_form.index}
41 + phx-target={@myself}
42 + class="text-red-600 hover:text-red-800"
43 + >
44 + <.icon name="hero-trash" class="h-4 w-4" />
45 + </.button>
46 + </div>
47 +
48 + <.input
49 + field={query_form[:type]}
50 + type="select"
51 + label="Query Type"
52 + options={[
53 + {"Alerts", :alerts},
54 + {"Time-based", :time},
55 + {"Pinned", :pinned},
56 + {"Background", :background}
57 + ]}
58 + />
59 +
60 + <%= render_query_fields(query_form) %>
61 + </div>
62 + </.inputs_for>
63 + </div>
64 +
65 + <div class="mt-4">
66 + <.button type="button" phx-click="add_query" phx-target={@myself}>
67 + <.icon name="hero-plus" class="h-4 w-4 mr-1" /> Add Query
68 + </.button>
69 + </div>
70 + </div>
71 +
72 + <:actions>
73 + <.button phx-disable-with="Saving...">Save Vision</.button>
74 + </:actions>
75 + </.simple_form>
76 + </div>
77 + """
25 78 end
26 79
27 80 @impl true
28 81 def update(%{vision: vision} = assigns, socket) do
29 − # Ensure vision has an empty queries list if it's nil or empty
30 − vision = ensure_queries_initialized(vision)
31 82 changeset = Configuration.change_vision(vision)
32 83
33 − {
34 − :ok,
84 + {:ok,
35 85 socket
36 86 |> assign(assigns)
37 − |> assign_form(changeset)
38 − |> assign(:cfg_queries, list_cfg_queries(assigns.current_user.id))
39 − |> assign(
40 − :cfg_queries_sel,
41 − list_cfg_queries(assigns.current_user.id)
42 − |> Enum.map(fn x -> {"#{x.name} (#{x.source.type})", x.id} end)
43 − |> Enum.into(%{})
44 − )
45 − }
46 − end
47 −
48 − defp ensure_queries_initialized(%{queries: queries} = vision) when is_list(queries), do: vision
49 − defp ensure_queries_initialized(vision) do
50 − %{vision | queries: []}
87 + |> assign_form(changeset)}
51 88 end
52 89
53 90 @impl true
54 91 def handle_event("validate", %{"vision" => vision_params}, socket) do
55 − IO.puts("cvl-valid")
56 − vision_params = inj_uid(vision_params, socket)
57 −
58 92 changeset =
59 93 socket.assigns.vision
60 94 |> Configuration.change_vision(vision_params)
61 95 |> Map.put(:action, :validate)
62 96
63 − # |> IO.inspect()
64 −
65 97 {:noreply, assign_form(socket, changeset)}
66 98 end
67 99
68 100 def handle_event("save", %{"vision" => vision_params}, socket) do
69 − IO.puts("cvl-save")
70 − vision_params = inj_uid(vision_params, socket) |> inj_types
71 101 save_vision(socket, socket.assigns.action, vision_params)
72 102 end
73 103
74 − def handle_event("add-entry", _data, socket) do
75 − # IO.inspect(socket.assigns.form)
76 − f = socket.assigns.form
77 −
78 − existing_as_simple =
79 − case f.params do
80 − map when map == %{} ->
81 − f.data.queries
82 − |> Poison.encode!()
83 − |> Poison.decode!()
84 − |> Enum.with_index()
85 − |> Enum.map(fn {k, v} -> {"#{v}", k} end)
86 − |> Map.new()
104 + def handle_event("query_type_changed", %{"index" => index, "value" => new_type}, socket) do
105 + existing_queries = Map.get(socket.assigns.form.params, "queries", %{})
87 106
88 − # |> Enum.map(fn {k,v} -> v end)
89 − otherwise ->
90 − f.params |> Map.get("queries")
107 + updated_query =
108 + case Map.get(existing_queries, index) do
109 + nil -> %{"type" => new_type}
110 + query ->
111 + order = get_in(query, ["data", "order"]) || 1
112 + Map.put(query, "data", build_data_for_type(new_type, order))
91 113 end
92 114
93 − # IO.inspect(existing_as_simple)
94 − combined =
95 − case existing_as_simple do
96 − list when list == [] ->
97 − %{
98 − "0" => %{
99 − "data" => %{"__type__" => "pinned", "query" => 0},
100 − "id" => nil,
101 − "type" => "pinned",
102 − "order" => 0
103 − }
104 − }
105 −
106 − _otherwise ->
107 − existing_as_simple
108 − |> Map.merge(%{
109 − "#{existing_as_simple |> Map.keys() |> length}" => %{
110 − "data" => %{"__type__" => "pinned", "query" => 0},
111 − "id" => nil,
112 − "type" => "pinned",
113 − "order" => 0
114 − }
115 − })
116 − end
115 + updated_params =
116 + socket.assigns.form.params
117 + |> Map.put("queries", Map.put(existing_queries, index, updated_query))
117 118
118 119 changeset =
119 120 socket.assigns.vision
120 − |> Configuration.change_vision(%{queries: combined})
121 + |> Configuration.change_vision(updated_params)
121 122 |> Map.put(:action, :validate)
122 123
123 − # |> IO.inspect()
124 + {:noreply, assign_form(socket, changeset)}
125 + end
124 126
125 − {
126 − :noreply,
127 − socket
128 − |> assign_form(changeset)
127 + defp build_data_for_type(type, order) do
128 + base_data = %{"__type__" => type, "order" => order}
129 +
130 + case type do
131 + "time" ->
132 + Map.merge(base_data, %{
133 + "query" => nil,
134 + "weekdays" => [],
135 + "time_start" => nil,
136 + "time_end" => nil
137 + })
138 +
139 + "background" ->
140 + # Background query is optional, so we don't include it
141 + base_data
142 +
143 + _ ->
144 + # alerts and pinned require query
145 + Map.put(base_data, "query", nil)
146 + end
147 + end
148 +
149 + def handle_event("add_query", _params, socket) do
150 + existing_queries = Map.get(socket.assigns.form.params, "queries", %{})
151 + next_index = map_size(existing_queries)
152 +
153 + new_query = %{
154 + "type" => "pinned",
155 + "data" => %{
156 + "__type__" => "pinned",
157 + "order" => next_index + 1,
158 + "query" => nil
159 + }
129 160 }
161 +
162 + updated_params =
163 + socket.assigns.form.params
164 + |> Map.put("queries", Map.put(existing_queries, to_string(next_index), new_query))
165 +
166 + changeset =
167 + socket.assigns.vision
168 + |> Configuration.change_vision(updated_params)
169 + |> Map.put(:action, :validate)
170 +
171 + {:noreply, assign_form(socket, changeset)}
172 + end
173 +
174 + def handle_event("remove_query", %{"index" => index}, socket) do
175 + existing_queries = Map.get(socket.assigns.form.params, "queries", %{})
176 + updated_queries = Map.delete(existing_queries, index)
177 +
178 + updated_params =
179 + socket.assigns.form.params
180 + |> Map.put("queries", updated_queries)
181 + |> Map.put("queries_drop", [index])
182 +
183 + changeset =
184 + socket.assigns.vision
185 + |> Configuration.change_vision(updated_params)
186 + |> Map.put(:action, :validate)
187 +
188 + {:noreply, assign_form(socket, changeset)}
130 189 end
131 190
132 191 defp save_vision(socket, :edit, vision_params) do
@@ -134,12 +193,10 @@ defmodule RoomSanctumWeb.VisionLive.FormComponent do
134 193 {:ok, vision} ->
135 194 notify_parent({:saved, vision})
136 195
137 − {
138 − :noreply,
196 + {:noreply,
139 197 socket
140 198 |> put_flash(:info, "Vision updated successfully")
141 − |> push_redirect(to: socket.assigns.patch)
142 − }
199 + |> push_patch(to: socket.assigns.patch)}
143 200
144 201 {:error, %Ecto.Changeset{} = changeset} ->
145 202 {:noreply, assign_form(socket, changeset)}
@@ -147,16 +204,16 @@ defmodule RoomSanctumWeb.VisionLive.FormComponent do
147 204 end
148 205
149 206 defp save_vision(socket, :new, vision_params) do
207 + vision_params = Map.put(vision_params, "user_id", socket.assigns.current_user.id)
208 +
150 209 case Configuration.create_vision(vision_params) do
151 210 {:ok, vision} ->
152 211 notify_parent({:saved, vision})
153 212
154 − {
155 − :noreply,
213 + {:noreply,
156 214 socket
157 215 |> put_flash(:info, "Vision created successfully")
158 − |> push_redirect(to: socket.assigns.patch)
159 − }
216 + |> push_patch(to: socket.assigns.patch)}
160 217
161 218 {:error, %Ecto.Changeset{} = changeset} ->
162 219 {:noreply, assign_form(socket, changeset)}
@@ -169,76 +226,104 @@ defmodule RoomSanctumWeb.VisionLive.FormComponent do
169 226
170 227 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
171 228
172 − defp list_cfg_queries(uid) do
173 − Configuration.list_cfg_queries({:user, uid})
174 − end
175 −
176 − defp safe_inputs_with_index(form, field) do
177 − case inputs_for(form, field) do
178 − {:safe, []} -> []
179 − inputs when is_list(inputs) -> Enum.with_index(inputs)
180 − _ -> []
229 + defp render_query_fields(query_form) do
230 + case Ecto.Changeset.get_field(query_form.source, :type) do
231 + :alerts -> render_alerts_fields(query_form)
232 + :time -> render_time_fields(query_form)
233 + :pinned -> render_pinned_fields(query_form)
234 + :background -> render_background_fields(query_form)
235 + _ -> render_empty_state()
181 236 end
182 237 end
183 238
184 − defp etuple(:U) do
185 − {"Sunday", "U"}
186 − end
187 −
188 − defp etuple(:M) do
189 − {"Monday", "M"}
239 + defp render_alerts_fields(query_form) do
240 + assigns = %{query_form: query_form}
241 +
242 + ~H"""
243 + <%= for data_form <- polymorphic_embed_inputs_for(@query_form, :data) do %>
244 + <%= hidden_inputs_for(data_form) %>
245 + <div class="grid grid-cols-2 gap-4 mt-4">
246 + <.input field={data_form[:query]} type="number" label="Query ID" />
247 + <.input field={data_form[:order]} type="number" label="Order" />
248 + </div>
249 + <% end %>
250 + """
190 251 end
191 252
192 − defp etuple(:T) do
193 − {"Tuesday", "T"}
253 + defp render_time_fields(query_form) do
254 + assigns = %{query_form: query_form}
255 +
256 + ~H"""
257 + <%= for data_form <- polymorphic_embed_inputs_for(@query_form, :data) do %>
258 + <%= hidden_inputs_for(data_form) %>
259 + <div class="space-y-4 mt-4">
260 + <div class="grid grid-cols-2 gap-4">
261 + <.input field={data_form[:query]} type="number" label="Query ID" />
262 + <.input field={data_form[:order]} type="number" label="Order" />
263 + </div>
264 +
265 + <div>
266 + <label class="block text-sm font-medium text-gray-700 mb-2">Active Days</label>
267 + <div class="flex gap-2">
268 + <%= for {label, value} <- [{"Sun", :U}, {"Mon", :M}, {"Tue", :T}, {"Wed", :W}, {"Thu", :R}, {"Fri", :F}, {"Sat", :S}] do %>
269 + <label class="inline-flex items-center">
270 + <input
271 + type="checkbox"
272 + name={"vision[queries][#{@query_form.index}][data][weekdays][]"}
273 + value={value}
274 + checked={value in (Ecto.Changeset.get_field(data_form.source, :weekdays) || [])}
275 + class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
276 + />
277 + <span class="ml-1 text-sm"><%= label %></span>
278 + </label>
279 + <% end %>
280 + </div>
281 + </div>
282 +
283 + <div class="grid grid-cols-2 gap-4">
284 + <.input field={data_form[:time_start]} type="time" label="Start Time" />
285 + <.input field={data_form[:time_end]} type="time" label="End Time" />
286 + </div>
287 + </div>
288 + <% end %>
289 + """
194 290 end
195 291
196 − defp etuple(:W) do
197 − {"Wednesday", "W"}
292 + defp render_pinned_fields(query_form) do
293 + assigns = %{query_form: query_form}
294 +
295 + ~H"""
296 + <%= for data_form <- polymorphic_embed_inputs_for(@query_form, :data) do %>
297 + <%= hidden_inputs_for(data_form) %>
298 + <div class="grid grid-cols-2 gap-4 mt-4">
299 + <.input field={data_form[:query]} type="number" label="Query ID" />
300 + <.input field={data_form[:order]} type="number" label="Order" />
301 + </div>
302 + <% end %>
303 + """
198 304 end
199 305
200 − defp etuple(:R) do
201 − {"Thursday", "R"}
306 + defp render_background_fields(query_form) do
307 + assigns = %{query_form: query_form}
308 +
309 + ~H"""
310 + <%= for data_form <- polymorphic_embed_inputs_for(@query_form, :data) do %>
311 + <%= hidden_inputs_for(data_form) %>
312 + <div class="grid grid-cols-2 gap-4 mt-4">
313 + <.input field={data_form[:query]} type="number" label="Query ID (optional)" />
314 + <.input field={data_form[:order]} type="number" label="Order" />
315 + </div>
316 + <% end %>
317 + """
202 318 end
203 319
204 − defp etuple(:F) do
205 − {"Friday", "F"}
206 − end
207 −
208 − defp etuple(:S) do
209 − {"Saturday", "S"}
210 − end
211 −
212 − defp gfv(form, fq, ctr) do
213 − # IO.puts("GFV")
214 − # IO.inspect(changeset)
215 − # IO.inspect(fq)
216 − #
217 − # IO.inspect(
218 − # fq.data
219 − # |> Map.get(:type)
220 − # )
221 − # IO.inspect(
222 − # fq.source.data
223 − # |> Map.get(:type)
224 − # )
225 −
226 − q =
227 − form.params |> Map.get("queries", %{}) |> Map.get("#{ctr}", %{}) |> Map.get("type") ||
228 − form.data
229 − |> Map.get(:queries, [])
230 − |> Enum.at(ctr, %{})
231 − |> Map.get(:changes, %{})
232 − |> Map.get(:type) ||
233 − fq.data
234 − |> Map.get(:source, %{})
235 − |> Map.get(:changes, %{})
236 − |> Map.get(:type) ||
237 − fq.source
238 − |> Map.get(:data, %{})
239 − |> Map.get(:type)
320 + defp render_empty_state do
321 + assigns = %{}
240 322
241 − # |> IO.inspect
242 − q
323 + ~H"""
324 + <div class="mt-4 text-sm text-gray-500">
325 + Select a query type to configure
326 + </div>
327 + """
243 328 end
244 −end
329 +end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/form_component.html.heex
+116 −61
@@ -1,77 +1,132 @@
1 1 <div>
2 − <h2 class="text-2xl font-bold text-accent"><%= @title %></h2>
2 + <.header>
3 + <%= @title %>
4 + <:subtitle>Configure your vision with custom queries</:subtitle>
5 + </.header>
3 6
4 − <.simple_form
7 + <.simple_form
5 8 for={@form}
6 9 id="vision-form"
7 10 phx-target={@myself}
8 11 phx-change="validate"
9 − phx-submit="save">
12 + phx-submit="save"
13 + >
14 + <.input field={@form[:name]} type="text" label="Name" />
10 15
11 − <div class="flex">
12 − <div class="w-full">
13 − <div class="form-control">
14 − <.input field={@form[:name]} type="text" label="Name"/>
15 − </div>
16 − </div>
17 − </div>
18 − <%= for {fq, ctr} <- safe_inputs_with_index(@form, :queries) do %>
19 − <%= hidden_inputs_for(fq) %>
20 − <div class="flex">
21 − <div class="flex-none w-16 font-bold text-7xl py-6">
22 − <%= ctr %>
23 − </div>
24 − <div class="flex-none w-32 p-1">
25 − <div class="form-control">
26 − <.input field={fq[:type]} type="select" label="Type" options={ Ecto.Enum.values(RoomSanctum.Configuration.Vision, :type)}/>
27 − </div>
16 + <.input field={@form[:public]} type="checkbox" label="Make this vision public (shown on homepage)" />
17 +
18 + <div class="mt-6">
19 + <h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Queries</h3>
20 +
21 + <div id="queries" class="space-y-4">
22 + <.inputs_for :let={query_form} field={@form[:queries]}>
23 + <div class="border rounded-lg p-4 relative">
24 + <input type="hidden" name="vision[queries_sort][]" value={query_form.index} />
25 +
26 + <div class="absolute top-2 right-2">
27 + <.button
28 + type="button"
29 + phx-click="remove_query"
30 + phx-value-index={query_form.index}
31 + phx-target={@myself}
32 + class="text-red-600 hover:text-red-800"
33 + >
34 + <.icon name="hero-trash" class="h-4 w-4" />
35 + </.button>
28 36 </div>
29 37
30 − <%= for fd <- polymorphic_embed_inputs_for fq, :data do %>
31 − <%= hidden_inputs_for(fd) %>
32 − <div class="flex flex-col">
33 − <div class="flex">
34 − <div class="flex-none w-24 p-1">
35 − <div class="form-control">
36 − <.input field={fd[:order]} type="number" label="Order" />
38 + <.input
39 + field={query_form[:type]}
40 + type="select"
41 + label="Query Type"
42 + options={[
43 + {"Alerts", :alerts},
44 + {"Time-based", :time},
45 + {"Pinned", :pinned},
46 + {"Background", :background}
47 + ]}
48 + />
49 +
50 + <%= case Ecto.Changeset.get_field(query_form.source, :type) do %>
51 + <% :alerts -> %>
52 + <%= for data_form <- polymorphic_embed_inputs_for(query_form, :data) do %>
53 + <%= hidden_inputs_for(data_form) %>
54 + <div class="grid grid-cols-2 gap-4 mt-4">
55 + <.input field={data_form[:query]} type="number" label="Query ID" />
56 + <.input field={data_form[:order]} type="number" label="Order" />
57 + </div>
58 + <% end %>
59 +
60 + <% :time -> %>
61 + <%= for data_form <- polymorphic_embed_inputs_for(query_form, :data) do %>
62 + <%= hidden_inputs_for(data_form) %>
63 + <div class="space-y-4 mt-4">
64 + <div class="grid grid-cols-2 gap-4">
65 + <.input field={data_form[:query]} type="number" label="Query ID" />
66 + <.input field={data_form[:order]} type="number" label="Order" />
37 67 </div>
38 − </div>
39 − <div class="flex-1 w-full p-1">
40 − <div class="form-control">
41 − <.input field={fd[:query]} type="select" label="Type" options={@cfg_queries_sel}/>
68 +
69 + <div>
70 + <label class="block text-sm font-medium text-gray-700 mb-2">Active Days</label>
71 + <div class="flex gap-2">
72 + <%= for {label, value} <- [{"Sun", :U}, {"Mon", :M}, {"Tue", :T}, {"Wed", :W}, {"Thu", :R}, {"Fri", :F}, {"Sat", :S}] do %>
73 + <label class="inline-flex items-center">
74 + <input
75 + type="checkbox"
76 + name={"vision[queries][#{query_form.index}][data][weekdays][]"}
77 + value={value}
78 + checked={value in (Ecto.Changeset.get_field(data_form.source, :weekdays) || [])}
79 + class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
80 + />
81 + <span class="ml-1 text-sm"><%= label %></span>
82 + </label>
83 + <% end %>
84 + </div>
42 85 </div>
43 − </div>
44 − </div>
45 − <div class="flex-1 w-full p-1">
46 − <%= case gfv(@form, fq, ctr) do %>
47 − <% val when val in [:time, "time"] -> %>
48 − <div class="flex">
49 − <div class="form-control">
50 − <.input field={fd[:weekdays]} type="select" multiple="true" label="Weekday" options={Enum.map(Ecto.Enum.values(RoomSanctum.Configuration.Vision.Schema1Time, :weekdays), fn c -> etuple(c) end)}/>
51 − </div>
52 − <div class="px-4
53 −">
54 − <label class="block text-md font-bold leading-6 pt-4 text-primary-content">Times</label>
55 − <div class="time-inputs">
56 − <.input field={fd[:time_start]} type="time" label="Start")} />
57 − </div>
58 − <div class="time-inputs">
59 − <.input field={fd[:time_end]} type="time" label="End")} />
60 − </div>
61 − </div>
86 +
87 + <div class="grid grid-cols-2 gap-4">
88 + <.input field={data_form[:time_start]} type="time" label="Start Time" />
89 + <.input field={data_form[:time_end]} type="time" label="End Time" />
62 90 </div>
63 − <% _ -> %>
91 + </div>
92 + <% end %>
93 +
94 + <% :pinned -> %>
95 + <%= for data_form <- polymorphic_embed_inputs_for(query_form, :data) do %>
96 + <%= hidden_inputs_for(data_form) %>
97 + <div class="grid grid-cols-2 gap-4 mt-4">
98 + <.input field={data_form[:query]} type="number" label="Query ID" />
99 + <.input field={data_form[:order]} type="number" label="Order" />
100 + </div>
64 101 <% end %>
102 +
103 + <% :background -> %>
104 + <%= for data_form <- polymorphic_embed_inputs_for(query_form, :data) do %>
105 + <%= hidden_inputs_for(data_form) %>
106 + <div class="grid grid-cols-2 gap-4 mt-4">
107 + <.input field={data_form[:query]} type="number" label="Query ID (optional)" />
108 + <.input field={data_form[:order]} type="number" label="Order" />
109 + </div>
110 + <% end %>
111 +
112 + <% _ -> %>
113 + <div class="mt-4 text-sm text-gray-500">
114 + Select a query type to configure
65 115 </div>
66 − </div>
67 − <% end %>
68 − </div>
69 − <% end %>
116 + <% end %>
117 + </div>
118 + </.inputs_for>
119 + </div>
70 120
71 − <br />
72 − <div class="flex m-2">
73 − <%= submit "Save", phx_disable_with: "Saving...", class: "btn btn-secondary mx-2" %>
74 − <button href="#" phx-click="add-entry" phx-target={@myself} class="btn btn-secondary mx-2">Add Entry</button>
121 + <div class="mt-4">
122 + <.button type="button" phx-click="add_query" phx-target={@myself}>
123 + <.icon name="hero-plus" class="h-4 w-4 mr-1" /> Add Query
124 + </.button>
125 + </div>
75 126 </div>
76 −</.simple_form>
77 −</div>
127 +
128 + <:actions>
129 + <.button phx-disable-with="Saving...">Save Vision</.button>
130 + </:actions>
131 + </.simple_form>
132 +</div>

Parents: 6138b19