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:
songtianlun 2025-02-12 14:47:30 +08:00
parent 799f3222a9
commit 80c2f9a1df
6 changed files with 172 additions and 54 deletions

View File

@ -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;
}

View File

@ -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

View File

@ -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')
}
}
}

View 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>

View File

@ -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] %>

View File

@ -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>