feat: add loading spinner and search updates
- Implement loading state in the search input with spinner. - Optimize the search request to handle pending requests and cancels. - Add dynamic response handling for Turbo frames to load search results. - Create a new partial for city search results. - Update the cities controller to support Turbo stream responses. These enhancements improve user experience during searches by showing a loading spinner and addressing potential issues with overlapping requests, ensuring that the application remains responsive and functional when fetching city search results.
This commit is contained in:
parent
799f3222a9
commit
80c2f9a1df
@ -4,3 +4,41 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
transform: translateY(-50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-right-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: translateY(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
@ -15,18 +15,17 @@ class CitiesController < ApplicationController
|
||||
@cities = @cities.by_region(@current_region.id) if @current_region
|
||||
end
|
||||
|
||||
if params[:country]
|
||||
@current_country = Country.friendly.find(params[:country])
|
||||
@cities = @cities.by_country(@current_country.id)
|
||||
end
|
||||
|
||||
@cities = @cities.page(params[:page]).per(12)
|
||||
|
||||
set_meta_tags(
|
||||
title: @current_region ? "Cities in #{@current_region.name}" : "Explore Cities",
|
||||
description: "Discover weather art for cities #{@current_region ? "in #{@current_region.name}" : 'worldwide'}. Real-time AI-generated weather visualization.",
|
||||
keywords: "#{@current_region&.name}, cities, weather art, AI visualization"
|
||||
)
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.turbo_stream {
|
||||
render turbo_stream: turbo_stream.update("cities_results",
|
||||
partial: "cities/results",
|
||||
locals: { cities: @cities }
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
|
@ -2,12 +2,71 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input"]
|
||||
static targets = ["input", "clearButton", "spinner", "statusIcon"]
|
||||
|
||||
connect() {
|
||||
this.pendingRequest = null
|
||||
}
|
||||
|
||||
submit() {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = setTimeout(() => {
|
||||
this.element.requestSubmit()
|
||||
// 如果有待处理的请求,则中止它
|
||||
if (this.pendingRequest) {
|
||||
this.pendingRequest.abort()
|
||||
}
|
||||
|
||||
const form = this.element
|
||||
const searchInput = this.inputTarget
|
||||
const encodedValue = encodeURIComponent(searchInput.value)
|
||||
|
||||
// 更新 URL
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('query', encodedValue)
|
||||
window.history.pushState({}, '', url)
|
||||
|
||||
// 显示加载状态
|
||||
this.showLoadingState()
|
||||
|
||||
// 发送请求
|
||||
this.pendingRequest = new AbortController()
|
||||
fetch(form.action + '?' + new URLSearchParams(new FormData(form)), {
|
||||
headers: {
|
||||
'Accept': 'text/vnd.turbo-stream.html',
|
||||
'Turbo-Frame': 'cities_results'
|
||||
},
|
||||
signal: this.pendingRequest.signal
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
Turbo.renderStreamMessage(html)
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.name === 'AbortError') return
|
||||
console.error('Search error:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.hideLoadingState()
|
||||
this.pendingRequest = null
|
||||
})
|
||||
}, 300)
|
||||
}
|
||||
|
||||
showLoadingState() {
|
||||
if (this.hasClearButtonTarget) {
|
||||
this.clearButtonTarget.classList.add('hidden')
|
||||
}
|
||||
if (this.hasSpinnerTarget) {
|
||||
this.spinnerTarget.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
hideLoadingState() {
|
||||
if (this.hasClearButtonTarget) {
|
||||
this.clearButtonTarget.classList.remove('hidden')
|
||||
}
|
||||
if (this.hasSpinnerTarget) {
|
||||
this.spinnerTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
24
app/views/cities/_results.html.erb
Normal file
24
app/views/cities/_results.html.erb
Normal file
@ -0,0 +1,24 @@
|
||||
<!-- app/views/cities/_results.html.erb -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<% if cities.empty? %>
|
||||
<div class="text-center py-16">
|
||||
<div class="text-base-content/50">
|
||||
<svg class="w-16 h-16 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold mb-2"><%= t('.no_results_title') %></h3>
|
||||
<p class="text-base-content/70">
|
||||
<%= t('.no_results_message') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<%= render partial: 'city', collection: cities %>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/pagination',
|
||||
collection: cities,
|
||||
collection_name: 'cities' %>
|
||||
<% end %>
|
||||
</div>
|
@ -1,27 +1,49 @@
|
||||
<div class="mt-8 max-w-2xl mx-auto">
|
||||
<%= form_with url: cities_path, method: :get, class: "relative", data: { controller: "search" } do |f| %>
|
||||
<div class="mt-8 mb-2 max-w-2xl mx-auto">
|
||||
<%= form_with url: cities_path, method: :get,
|
||||
class: "relative",
|
||||
data: {
|
||||
controller: "search",
|
||||
turbo_frame: "cities_results",
|
||||
turbo_action: "advance"
|
||||
} do |f| %>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
||||
<svg class="w-5 h-5 text-base-content/50" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<!-- 搜索图标 -->
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-4 z-10">
|
||||
<svg class="w-5 h-5 text-base-content/70" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 搜索输入框 -->
|
||||
<%= f.text_field :query,
|
||||
value: params[:query] ? URI.decode_www_form_component(params[:query]) : nil,
|
||||
class: "w-full pl-12 pr-4 py-3 rounded-full bg-base-200/50 backdrop-blur-sm border border-base-300 focus:outline-none focus:ring-2 focus:ring-primary/50",
|
||||
class: "w-full pl-12 pr-12 py-3 rounded-full bg-base-200/80 backdrop-blur border border-base-300 focus:outline-none focus:ring-2 focus:ring-primary/50 transition",
|
||||
placeholder: "Search cities...",
|
||||
autocomplete: "off",
|
||||
data: {
|
||||
action: "input->search#submit",
|
||||
search_target: "input"
|
||||
} %>
|
||||
<% if params[:query].present? %>
|
||||
<%= link_to cities_path, class: "absolute inset-y-0 right-0 flex items-center pr-4" do %>
|
||||
<svg class="w-5 h-5 text-base-content/50 hover:text-base-content" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
||||
<!-- 右侧按钮区域(清除按钮或加载动画) -->
|
||||
<!-- 更简单的环形 loading 图标版本 -->
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-4 z-10"
|
||||
data-search-target="statusIcon">
|
||||
<% if params[:query].present? %>
|
||||
<%= link_to cities_path,
|
||||
class: "text-base-content/50 hover:text-base-content transition",
|
||||
data: { search_target: "clearButton" } do %>
|
||||
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<!-- 简单的环形 loading -->
|
||||
<div class="hidden" data-search-target="spinner">
|
||||
<div class="w-5 h-5 border-2 border-base-content/20 border-t-base-content/70 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<%= f.hidden_field :region, value: params[:region] if params[:region] %>
|
||||
|
@ -22,8 +22,6 @@
|
||||
Discover AI-generated weather art from cities around the world
|
||||
</p>
|
||||
|
||||
<%= render 'cities/search_city' %>
|
||||
|
||||
<!-- 特色图片信息 -->
|
||||
<% if featured_art %>
|
||||
<div class="inline-block mt-6 px-4 py-2 bg-base-100/80 backdrop-blur-sm rounded-full text-sm">
|
||||
@ -34,11 +32,15 @@
|
||||
<%= featured_art.weather_date.strftime("%B %d, %Y") %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'cities/search_city' %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="sticky top-16 z-20 bg-base-100/95 backdrop-blur-sm border-b border-base-200">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="py-3 flex items-center justify-between gap-4">
|
||||
@ -113,34 +115,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag "cities_results" do %>
|
||||
<%= render "cities/results", cities: @cities %>
|
||||
<% end %>
|
||||
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
|
||||
<% if @cities.empty? %>
|
||||
<div class="text-center py-16">
|
||||
<div class="text-base-content/50">
|
||||
<svg class="w-16 h-16 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold mb-2">No cities found</h3>
|
||||
<p class="text-base-content/70">
|
||||
Try adjusting your search or filters to find what you're looking for.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- 现有的城市网格代码 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<%= render partial: 'city', collection: @cities %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render 'shared/pagination',
|
||||
collection: @cities,
|
||||
collection_name: 'cities' %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
Loading…
Reference in New Issue
Block a user