diff --git a/app/assets/images/onsen.svg b/app/assets/images/onsen.svg new file mode 100644 index 0000000..4e68ede --- /dev/null +++ b/app/assets/images/onsen.svg @@ -0,0 +1,75 @@ + + + + + Onsen Mark 02 + + + + + + + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + Onsen + 2013-10-23T08:26:49 + Hot spring mark commonly used in Japan. + https://openclipart.org/detail/185361/onsen-by-uroesch-185361 + + + uroesch + + + + + Hot Spring + Japan + Onsen + + + + + + + + + + + diff --git a/app/javascript/controllers/form_map_controller.js b/app/javascript/controllers/form_map_controller.js new file mode 100644 index 0000000..361dfc3 --- /dev/null +++ b/app/javascript/controllers/form_map_controller.js @@ -0,0 +1,90 @@ +import { Controller } from "@hotwired/stimulus" +import L from "leaflet" + +// Connects to data-controller="form-map" +export default class extends Controller { + static targets = ["container", "latitudeInput", "longitudeInput"] + + connect() { + this.currentMarker = null; + + // 地図の初期化 + this.map = L.map(this.containerTarget).setView([35.468, 133.0483], 11.5); + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap' + }).addTo(this.map); + + // フォームに初期値があればピンを立てる + if (this.hasLatitudeInputTarget && this.hasLongitudeInputTarget && this.latitudeInputTarget.value && this.longitudeInputTarget.value) { + this.updateMarkerAndInputs(this.latitudeInputTarget.value, this.longitudeInputTarget.value); + } + + // マップクリック時のイベントリスナー + this.map.on("click", (e) => { + const { lat, lng } = e.latlng; + this.updateMarkerAndInputs(lat, lng); + }); + } + + // マーカーを更新し、フォームの値を自動入力 + updateMarkerAndInputs(lat, lng) { + this.removeMarker(); // 既存のピンを削除 + + const latlng = [lat, lng]; + this.currentMarker = L.marker(latlng).addTo(this.map) + .bindPopup(`緯度: ${lat.toFixed(5)}
経度: ${lng.toFixed(5)}`) + .openPopup(); + + this.map.setView(latlng, this.map.getZoom()); + this.latitudeInputTarget.value = lat.toFixed(5); + this.longitudeInputTarget.value = lng.toFixed(5); + } + + // 既存のピンを削除する + removeMarker() { + if (this.currentMarker) { + this.map.removeLayer(this.currentMarker); + } + } + + // フォームの入力値からマーカーを更新 + updateMarkerFromInput() { + const lat = parseFloat(this.latitudeInputTarget.value); + const lng = parseFloat(this.longitudeInputTarget.value); + + // 有効な数値であることを確認 + if (!isNaN(lat) && !isNaN(lng)) { + this.updateMarkerAndInputs(lat, lng); + } + } + + /** + * 現在地を取得し、地図とフォームを更新する + */ + locate() { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const lat = position.coords.latitude; + const lng = position.coords.longitude; + this.updateMarkerAndInputs(lat, lng); + }, + (error) => { + console.error("現在地の取得に失敗しました: ", error); + alert("現在地の取得に失敗しました。ブラウザの設定をご確認ください。"); + } + ); + } else { + alert("お使いのブラウザは現在地の取得に対応していません。"); + } + } + + // 切断時の処理 + disconnect() { + if (this.map) { + this.map.remove(); + this.map = null; + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/index_map_controller.js b/app/javascript/controllers/index_map_controller.js new file mode 100644 index 0000000..e425f71 --- /dev/null +++ b/app/javascript/controllers/index_map_controller.js @@ -0,0 +1,99 @@ +import { Controller } from "@hotwired/stimulus" +import L from "leaflet" + +// Connects to data-controller="index-map" +export default class extends Controller { + static targets = ["container"] + static values = { photoSpots: Array } + + connect() { + console.log(this.photoSpotsValue) + + // 地図の初期化 + this.map = L.map(this.containerTarget).setView([35.474, 133.050], 13) + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap' + }).addTo(this.map) + + // フォトスポットのマーカー + this.photoSpotsValue.forEach(photo_spot => { + if (photo_spot.latitude && photo_spot.longitude) { + const marker = L.marker([photo_spot.latitude, photo_spot.longitude]) + .addTo(this.map) + .bindPopup(photo_spot.name) + + // マーカークリック時に対応カードをハイライト + marker.on("click", () => { + this.highlight(null, photo_spot.id) + }) + } + }) + + // イベントリスナーを登録 + this.map.on("locationfound", this._onLocationFound.bind(this)) + this.map.on("locationerror", this._onLocationError.bind(this)) + + // 初回ロード時にも現在地を取得 + this.locate() + } + + locate() { + if (navigator.geolocation) { + this.map.locate({ setView: true, maxZoom: 16 }) + } else { + alert("お使いのブラウザは現在地の取得に対応していません。") + } + } + + _onLocationFound(e) { + const redIcon = new L.Icon({ + iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] + }) + + L.marker(e.latlng, { icon: redIcon }) + .addTo(this.map) + .bindPopup("現在地") + .openPopup() + } + + _onLocationError(e) { + alert("現在地を取得できませんでした: " + e.message) + } + + highlight(event, spotId = null) { + // イベントから spotId を取得 + const targetSpotId = spotId || event?.currentTarget.dataset.spotId + console.log("ハイライト対象ID:", targetSpotId) + + // 既存ハイライトを削除 + document.querySelectorAll("[data-spot-id]").forEach(el => { + el.classList.remove("ring-2", "ring-blue-500", "bg-blue-50", "animate-pulse") + }) + + // 対象カードを取得 + const card = document.querySelector(`[data-spot-id='${targetSpotId}']`) + console.log("見つかったカード:", card) + + if (card) { + card.classList.add("ring-2", "ring-blue-500", "bg-blue-50", "animate-pulse") + card.scrollIntoView({ behavior: "smooth", block: "center" }) + setTimeout(() => card.classList.remove("animate-pulse"), 2000) + } + } + + + disconnect() { + if (this.map) { + this.map.off("locationfound") + this.map.off("locationerror") + this.map.remove() + this.map = null + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js deleted file mode 100644 index 02dacd4..0000000 --- a/app/javascript/controllers/map_controller.js +++ /dev/null @@ -1,103 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import L from "leaflet" - -// Connects to data-controller="map" -export default class extends Controller { - // ターゲット名を変更しました - static targets = ["container", "latitudeInput", "longitudeInput"] - static values = { photo_spots: Array } - - connect() { - this.currentMarker = null; - this.photo_spots = this._parsePhotoSpotsData(); - - // 地図の初期化 - this.map = L.map(this.containerTarget).setView([35.468, 133.0483], 11.5); - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap' - }).addTo(this.map); - - // 既存のスポットのマーカーを設置 - const photo_spotIcon = L.icon({ - iconUrl: '/photo_spot.svg', - iconSize: [32, 32], - iconAnchor: [16, 32], - }); - this.photo_spots.forEach(photo_spot => { - // プロパティ名を変更しました - L.marker([photo_spot.latitude, photo_spot.longitude], { icon: photo_spotIcon }) - .addTo(this.map) - .bindPopup(photo_spot.name); - }); - - // フォームに初期値があればピンを立てる - if (this.latitudeInputTarget.value && this.longitudeInputTarget.value) { - this.updateMarker(this.latitudeInputTarget.value, this.longitudeInputTarget.value); - } - - // マップクリック時のイベントリスナー - this.map.on("click", (e) => { - const { lat, lng } = e.latlng; - this.updateMarker(lat, lng); - // テキストボックスの値を更新 - this.latitudeInputTarget.value = lat.toFixed(5); - this.longitudeInputTarget.value = lng.toFixed(5); - }); - } - - // マーカーを更新し、マップの中心を移動する - updateMarker(lat, lng) { - if (this.currentMarker) { - this.map.removeLayer(this.currentMarker); - } - const latlng = [lat, lng]; - this.currentMarker = L.marker(latlng).addTo(this.map); - this.map.setView(latlng, this.map.getZoom()); - } - - // フォームの入力値からマーカーを更新する - updateMarkerFromInput() { - const lat = parseFloat(this.latitudeInputTarget.value); - const lng = parseFloat(this.longitudeInputTarget.value); - - if (!isNaN(lat) && !isNaN(lng)) { - this.updateMarker(lat, lng); - } - } - - // 現在地を取得するメソッドを追加 - locate() { - if (navigator.geolocation) { - navigator.geolocation.getCurrentPosition( - (position) => { - const lat = position.coords.latitude; - const lng = position.coords.longitude; - this.updateMarker(lat, lng); - this.latitudeInputTarget.value = lat.toFixed(5); - this.longitudeInputTarget.value = lng.toFixed(5); - }, - (error) => { - console.error("現在地の取得に失敗しました: ", error); - alert("現在地の取得に失敗しました。ブラウザの設定をご確認ください。"); - } - ); - } else { - alert("お使いのブラウザは現在地の取得に対応していません。"); - } - } - - // === プライベートメソッド(内部処理用) === - - _parsePhotoSpotsData() { - try { - const rawData = this.element.dataset.mapPhotoSpots || "[]"; - // パース後のデータが{id:..., name:..., geo_lat:..., geo_lng:...}のような構造であれば - // 必要に応じてキーをリマップする必要があります - return JSON.parse(rawData); - } catch (error) { - console.warn("写真スポットデータのパースに失敗:", error); - return []; - } - } -} \ No newline at end of file diff --git a/app/views/admin/photo_spots/_form.html.erb b/app/views/admin/photo_spots/_form.html.erb index 741ca3c..ef80bfd 100644 --- a/app/views/admin/photo_spots/_form.html.erb +++ b/app/views/admin/photo_spots/_form.html.erb @@ -20,69 +20,71 @@
- <%= form.label :timestart, '時間帯(開始)', class: 'block font-semibold mb-1' %> - <%= form.select :timestart, options_for_select((0..23).map { |h| ["#{h}:00", "#{h}:00"] } , selected: photo_spot.timestart&.strftime("%H:%M")), { include_blank: '選択してください' }, { class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" } %> + <%= form.label :timestart, '時間帯(開始)', class: 'block font-semibold mb-1' %> + <%= form.select :timestart, options_for_select((0..23).map { |h| ["#{h}:00", "#{h}:00"] } , selected: photo_spot.timestart&.strftime("%H:%M")), { include_blank: '選択してください' }, { class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" } %>
- <%= form.label :timeend, '時間帯(終了)', class: 'block font-semibold mb-1' %> - <%= form.select :timeend, options_for_select((0..23).map { |h| ["#{h}:00", "#{h}:00"] } , selected: photo_spot.timestart&.strftime("%H:%M")), { include_blank: '選択してください' }, { class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" } %> + <%= form.label :timeend, '時間帯(終了)', class: 'block font-semibold mb-1' %> + <%= form.select :timeend, options_for_select((0..23).map { |h| ["#{h}:00", "#{h}:00"] } , selected: photo_spot.timestart&.strftime("%H:%M")), { include_blank: '選択してください' }, { class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" } %>
<%= form.label :season, 'おすすめの季節', class: 'block font-semibold mb-1' %> <%= form.select :season, options_for_select([['春', 'spring'], ['夏', 'summer'], ['秋', 'autumn'], ['冬', 'winter']], selected: photo_spot.season), { include_blank: '選択してください' }, { class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" } %> - <%# 緯度・経度の入力フィールド %> -
-
- <%= form.label :latitude, '緯度', class: 'block font-semibold mb-1' %> - <%= form.text_field :latitude, - data: { map_target: 'latitudeInput', action: 'input->map#updateMarkerFromInput' }, + <%# 地図エリア(デスクトップ時) %> +
+
+
+ +
+ <%# 緯度・経度の入力フィールドを地図コントローラーのスコープ内に配置 %> +
+
+ <%= form.label :latitude, '緯度', class: 'block font-semibold mb-1' %> + <%= form.text_field :latitude, + data: { form_map_target: 'latitudeInput', action: 'input->form-map#updateMarkerFromInput' }, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> -
-
- <%= form.label :longitude, '経度', class: 'block font-semibold mb-1' %> - <%= form.text_field :longitude, - data: { map_target: 'longitudeInput', action: 'input->map#updateMarkerFromInput' }, +
+
+ <%= form.label :longitude, '経度', class: 'block font-semibold mb-1' %> + <%= form.text_field :longitude, + data: { form_map_target: 'longitudeInput', action: 'input->form-map#updateMarkerFromInput' }, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> -
-
- <%# 地図エリア(デスクトップ時) %> -
-
- -
-
- <%= form.label :detail, '説明', class: 'block font-semibold mb-1' %> - <%= form.text_area :detail, rows: 4, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> -
-
- <%= form.label :tags, 'タグ(カンマ区切り)', class: 'block font-semibold mb-1' %> - <%= form.text_field :tags, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> -
-
- <%= form.label :parking_flag, '駐車場の有無', class: 'block font-semibold mb-1' %> - <%= form.text_field :parking_flag, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> -
-
- <%= form.label :images, '画像(最大5枚・JPEG/PNG/GIF)', class: 'block font-semibold mb-1' %> - <%= form.file_field :images, multiple: true, accept: 'image/jpeg,image/png,image/gif', class: "block border rounded px-2 py-1 w-full" %> - <% if photo_spot.images.attached? %> -
- <% photo_spot.images.each_with_index do |img, idx| %> -
- <%= image_tag img.variant(resize_to_limit: [100,100]), class: "rounded shadow border w-24 h-24 object-cover", alt: photo_spot.name %> - -
- <% end %> +
- <% end %> -
-
- <%= form.submit class: "rounded-md px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white font-semibold w-full sm:w-auto" %> -
-<% end %> \ No newline at end of file +
+
+ <%= form.label :detail, '説明', class: 'block font-semibold mb-1' %> + <%= form.text_area :detail, rows: 4, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> +
+
+ <%= form.label :tags, 'タグ(カンマ区切り)', class: 'block font-semibold mb-1' %> + <%= form.text_field :tags, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> +
+
+ <%= form.label :parking_flag, '駐車場の有無', class: 'block font-semibold mb-1' %> + <%= form.text_field :parking_flag, class: "block shadow-sm rounded-md border px-3 py-2 w-full border-gray-300 focus:outline-blue-600" %> +
+
+ <%= form.label :images, '画像(最大5枚・JPEG/PNG/GIF)', class: 'block font-semibold mb-1' %> + <%= form.file_field :images, multiple: true, accept: 'image/jpeg,image/png,image/gif', class: "block border rounded px-2 py-1 w-full" %> + <% if photo_spot.images.attached? %> +
+ <% photo_spot.images.each_with_index do |img, idx| %> +
+ <%= image_tag img.variant(resize_to_limit: [100,100]), class: "rounded shadow border w-24 h-24 object-cover", alt: photo_spot.name %> + +
+ <% end %> +
+ <% end %> +
+
+ <%= form.submit class: "rounded-md px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white font-semibold w-full sm:w-auto" %> +
+ <% end %> \ No newline at end of file diff --git a/app/views/photo_spots/_spot_card.html.erb b/app/views/photo_spots/_spot_card.html.erb index fc339a8..f0b426a 100644 --- a/app/views/photo_spots/_spot_card.html.erb +++ b/app/views/photo_spots/_spot_card.html.erb @@ -1,5 +1,7 @@ -<%# フォトスポットスポットカード(Atomic Design: Molecule) %> -<%= link_to photo_spot_path(photo_spot), class: "block bg-white rounded-lg shadow-md hover:shadow-lg p-4 hover:bg-blue-50 transition-all duration-200 cursor-pointer border border-gray-100 focus:outline focus:ring-2 focus:ring-blue-400", 'aria-label': "#{photo_spot.name}の詳細" do %> +<%= link_to photo_spot_path(photo_spot), + class: "block bg-white rounded-lg shadow-md hover:shadow-lg p-4 hover:bg-blue-50 transition-all duration-200 border border-gray-100 focus:outline focus:ring-2 focus:ring-blue-400", + 'aria-label': "#{photo_spot.name}の詳細", + data: { spot_id: photo_spot.id } do %>
<% if photo_spot.images.attached? %> <%= image_tag photo_spot.images.first, class: "w-20 h-20 object-cover rounded-lg flex-shrink-0", alt: photo_spot.name %> @@ -11,7 +13,7 @@
<%= truncate(photo_spot.detail, length: 60) %>
<% if photo_spot.tags.present? %>
- タグ: <%= photo_spot.tags%> + タグ: <%= photo_spot.tags %>
<% end %>
diff --git a/app/views/photo_spots/index.html.erb b/app/views/photo_spots/index.html.erb index e8ed65a..5b614b4 100644 --- a/app/views/photo_spots/index.html.erb +++ b/app/views/photo_spots/index.html.erb @@ -7,17 +7,20 @@ <%= render 'search_form' %> <%# メインコンテンツ:地図 + フォトスポット一覧 %>
+ <%# 左側:地図 %>
-
-
+
+
- <%# フォトスポット一覧エリア(右側・デスクトップ時) %> + <%# 右側:フォトスポット一覧 %>
<% if @photo_spots.any? %> @@ -36,3 +39,4 @@
+
\ No newline at end of file