Compare commits
128 Commits
Author | SHA1 | Date | |
---|---|---|---|
31c2913ea6 | |||
884e1dfc9f | |||
5d1846e0a0 | |||
130a8983a1 | |||
d69a193e6d | |||
2c72e96977 | |||
378530cc1b | |||
536b97a7da | |||
f2951e2741 | |||
9417358625 | |||
|
31bd6fd74e | ||
|
e36448e6d7 | ||
|
11df3a31d1 | ||
|
2c3f8e1b8e | ||
9a745dcee0 | |||
09b3f06ad4 | |||
05449d1e7f | |||
905ec35fd8 | |||
0352923c5b | |||
bbf8dfc2e6 | |||
15d64b94b0 | |||
62bfe8888e | |||
42efca67c7 | |||
138d610c3a | |||
c332230709 | |||
f0f94de528 | |||
069b6d4a4f | |||
2a0226eb68 | |||
8cd4c50024 | |||
4020f89271 | |||
29de36f5fb | |||
2e438166ee | |||
bf2ff282bb | |||
ce5d09b621 | |||
c68fecf3fa | |||
16ee512b0c | |||
111fd85ebb | |||
59a3f792c6 | |||
9e666310cf | |||
cc74145033 | |||
3e713a9b26 | |||
263c85486c | |||
a895216bda | |||
75cdd69b9b | |||
70f977cae1 | |||
444c3e67bc | |||
ffbd201d62 | |||
eb06398308 | |||
93543c6db3 | |||
5bfa94bc82 | |||
a16b14c7ca | |||
f59955ca6e | |||
978cec359e | |||
6eca78da8d | |||
f918a42619 | |||
adb671e668 | |||
50321533f7 | |||
dd6cd0451d | |||
5f30e08a6e | |||
bf10e41c1e | |||
9d1ff31c53 | |||
155669866a | |||
8cacf2a9ff | |||
b0bdb72f8e | |||
cea07ccfea | |||
5c8308a991 | |||
5feaee4922 | |||
ead795266e | |||
1ca468f9af | |||
1f35664590 | |||
40631fe95b | |||
f7c5ae4ee7 | |||
742c94ced1 | |||
c37a93bcdf | |||
7ebf9cefae | |||
dd37e2835b | |||
4e1fb58abf | |||
5bc06007b2 | |||
84c224cf8d | |||
032ff0552a | |||
3203face6b | |||
8364d42759 | |||
9ce473dddb | |||
fe55437c96 | |||
ec3669249f | |||
f6270b1ad4 | |||
9dd7044a77 | |||
dd6bb9972c | |||
23fc14af59 | |||
fedb954d34 | |||
7612dd6bd9 | |||
b4af78aa77 | |||
b05cf10017 | |||
06a861c639 | |||
2cd23a6047 | |||
80a75d3fbb | |||
f477f205ab | |||
1f47ba59c9 | |||
6544f0247c | |||
a0516f731c | |||
18f751938f | |||
2759646145 | |||
665f6f29b6 | |||
bafb90f5fb | |||
f33fb4d2ba | |||
a4de04874d | |||
3a6d247451 | |||
b3089856c2 | |||
ccb48a387b | |||
e1f9118ead | |||
af95c2e55f | |||
91e62234b4 | |||
6eb8d10965 | |||
97d7930daa | |||
a15bc349a2 | |||
5fa49d97ca | |||
dffac6c665 | |||
c5101fb822 | |||
fd6292a81e | |||
c529f5fd7b | |||
c1fa16c690 | |||
2d5521c3dc | |||
799dfc18ed | |||
a2c75ba3c2 | |||
e9095ece6e | |||
c20ff296eb | |||
f74f34ce82 | |||
a6cea6b80d |
48
.github/workflows/docker-dev.yml
vendored
Normal file
48
.github/workflows/docker-dev.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 获取完整的 git history 以便生成正确的 tag
|
||||
-
|
||||
name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
echo "VERSION=$(git describe --dirty --always --tags --abbrev=7)" >> $GITHUB_OUTPUT
|
||||
-
|
||||
name: Login to ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:dev
|
@ -47,5 +47,5 @@ jobs:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:latest
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:${{ steps.get_version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:main
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:${{ steps.get_version.outputs.VERSION }}
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -38,3 +38,5 @@
|
||||
|
||||
/node_modules
|
||||
.idea
|
||||
|
||||
public/sitemap.xml.gz
|
||||
|
@ -75,7 +75,7 @@ COPY --from=build /rails /rails
|
||||
# Run and own only the runtime files as a non-root user for security
|
||||
RUN groupadd --system --gid 1000 rails && \
|
||||
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
|
||||
chown -R rails:rails db log storage tmp
|
||||
chown -R rails:rails db log storage tmp public
|
||||
USER 1000:1000
|
||||
|
||||
# Entrypoint prepares the database.
|
||||
|
13
Gemfile
13
Gemfile
@ -45,7 +45,14 @@ gem "devise", "~> 4.9"
|
||||
gem "activeadmin", "~> 3.2"
|
||||
gem "friendly_id", "~> 5.5"
|
||||
|
||||
gem "whenever", "~> 1.0"
|
||||
gem "kaminari", "~> 1.2"
|
||||
|
||||
gem "meta-tags", "~> 2.22"
|
||||
gem "sitemap_generator", "~> 6.3"
|
||||
|
||||
gem "ahoy_matey", "~> 5.2"
|
||||
|
||||
# gem "whenever", "~> 1.0"
|
||||
gem "ruby-openai", "~> 7.3"
|
||||
gem "httparty", "~> 0.22.0"
|
||||
gem "down", "~> 5.4"
|
||||
@ -53,6 +60,10 @@ gem "aws-sdk-s3", "~> 1.177"
|
||||
gem "sidekiq", "~> 7.3"
|
||||
gem "sidekiq-scheduler", "~> 5.0"
|
||||
|
||||
gem "image_processing", "~> 1.13"
|
||||
gem "ruby-vips", "~> 2.2"
|
||||
gem "mini_magick", "~> 4.13.2"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
||||
|
92
Gemfile.lock
92
Gemfile.lock
@ -84,22 +84,26 @@ GEM
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ahoy_matey (5.3.0)
|
||||
activesupport (>= 7)
|
||||
device_detector (>= 1)
|
||||
safely_block (>= 0.4)
|
||||
arbre (1.7.0)
|
||||
activesupport (>= 3.0.0)
|
||||
ruby2_keywords (>= 0.0.2)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1035.0)
|
||||
aws-sdk-core (3.215.0)
|
||||
aws-partitions (1.1044.0)
|
||||
aws-sdk-core (3.217.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-kms (1.97.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-s3 (1.179.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
@ -126,7 +130,6 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chronic (0.10.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
crass (1.0.6)
|
||||
@ -137,6 +140,7 @@ GEM
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
device_detector (1.1.3)
|
||||
devise (4.9.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
@ -160,6 +164,14 @@ GEM
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
ffi (1.17.1-arm-linux-gnu)
|
||||
ffi (1.17.1-arm-linux-musl)
|
||||
ffi (1.17.1-arm64-darwin)
|
||||
ffi (1.17.1-x86_64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
formtastic (5.0.0)
|
||||
actionpack (>= 6.0.0)
|
||||
formtastic_i18n (0.7.0)
|
||||
@ -177,15 +189,19 @@ GEM
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.6)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
inherited_resources (1.14.0)
|
||||
actionpack (>= 6.0)
|
||||
has_scope (>= 0.6)
|
||||
railties (>= 6.0)
|
||||
responders (>= 2)
|
||||
io-console (0.8.0)
|
||||
irb (1.14.3)
|
||||
irb (1.15.1)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.13.0)
|
||||
@ -222,7 +238,7 @@ GEM
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
language_server-protocol (3.17.0.3)
|
||||
language_server-protocol (3.17.0.4)
|
||||
logger (1.6.5)
|
||||
loofah (2.24.0)
|
||||
crass (~> 1.0.2)
|
||||
@ -234,6 +250,9 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
meta-tags (2.22.1)
|
||||
actionpack (>= 6.0.0, < 8.1)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
msgpack (1.7.5)
|
||||
@ -249,7 +268,7 @@ GEM
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-scp (4.0.0)
|
||||
net-scp (4.1.0)
|
||||
net-ssh (>= 2.6.5, < 8.0.0)
|
||||
net-sftp (4.0.0)
|
||||
net-ssh (>= 5.0.0, < 8.0.0)
|
||||
@ -257,21 +276,21 @@ GEM
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.1-aarch64-linux-gnu)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-aarch64-linux-musl)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-arm-linux-gnu)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-arm-linux-musl)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-arm64-darwin)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-x86_64-darwin)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-x86_64-linux-gnu)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.1-x86_64-linux-musl)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
@ -280,6 +299,9 @@ GEM
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@ -289,11 +311,11 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.5.0)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.8)
|
||||
rack (3.1.9)
|
||||
rack-session (2.1.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@ -347,17 +369,17 @@ GEM
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.4.0)
|
||||
rubocop (1.70.0)
|
||||
rubocop (1.71.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.37.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.36.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
@ -365,7 +387,7 @@ GEM
|
||||
rubocop-performance (1.23.1)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.29.0)
|
||||
rubocop-rails (2.29.1)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
@ -380,10 +402,14 @@ GEM
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safely_block (0.4.1)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.28.0)
|
||||
base64 (~> 0.2)
|
||||
@ -401,7 +427,9 @@ GEM
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 6, < 8)
|
||||
tilt (>= 1.4.0, < 3)
|
||||
solid_cable (3.0.5)
|
||||
sitemap_generator (6.3.0)
|
||||
builder (~> 3.0)
|
||||
solid_cable (3.0.7)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
@ -410,7 +438,7 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.1.2)
|
||||
solid_queue (1.1.3)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@ -464,8 +492,6 @@ GEM
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
whenever (1.0.0)
|
||||
chronic (>= 0.6.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.1)
|
||||
@ -484,6 +510,7 @@ PLATFORMS
|
||||
|
||||
DEPENDENCIES
|
||||
activeadmin (~> 3.2)
|
||||
ahoy_matey (~> 5.2)
|
||||
aws-sdk-s3 (~> 1.177)
|
||||
bootsnap
|
||||
brakeman
|
||||
@ -494,18 +521,24 @@ DEPENDENCIES
|
||||
down (~> 5.4)
|
||||
friendly_id (~> 5.5)
|
||||
httparty (~> 0.22.0)
|
||||
image_processing (~> 1.13)
|
||||
jbuilder
|
||||
jsbundling-rails
|
||||
kamal
|
||||
kaminari (~> 1.2)
|
||||
meta-tags (~> 2.22)
|
||||
mini_magick (~> 4.13.2)
|
||||
pg (~> 1.5)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.0.1)
|
||||
rubocop-rails-omakase
|
||||
ruby-openai (~> 7.3)
|
||||
ruby-vips (~> 2.2)
|
||||
selenium-webdriver
|
||||
sidekiq (~> 7.3)
|
||||
sidekiq-scheduler (~> 5.0)
|
||||
sitemap_generator (~> 6.3)
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
@ -515,7 +548,6 @@ DEPENDENCIES
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
web-console
|
||||
whenever (~> 1.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.2
|
||||
|
@ -1,4 +1,5 @@
|
||||
ActiveAdmin.register AdminUser do
|
||||
menu label: "AdminUser Manager", parent: "系统管理"
|
||||
permit_params :email, :password, :password_confirmation
|
||||
|
||||
index do
|
||||
|
83
app/admin/ahoy_dashboard.rb
Normal file
83
app/admin/ahoy_dashboard.rb
Normal file
@ -0,0 +1,83 @@
|
||||
ActiveAdmin.register_page "Ahoy Dashboard" do
|
||||
menu label: "总览", parent: "数据统计", priority: 1
|
||||
page_action :toggle_city_status, method: :post do
|
||||
city = City.find(params[:city_id])
|
||||
city.update(active: !city.active)
|
||||
redirect_back(fallback_location: admin_dashboard_path, notice: "城市状态已更新")
|
||||
end
|
||||
|
||||
content title: "总览" do
|
||||
columns do
|
||||
column do
|
||||
panel "访问统计" do
|
||||
para "总访问量: #{Ahoy::Visit.count}"
|
||||
para "总事件数: #{Ahoy::Event.count}"
|
||||
para "独立访客数: #{Ahoy::Visit.distinct.count(:visitor_token)}"
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门城市" do
|
||||
table_for City.by_popularity.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门天气艺术" do
|
||||
table_for WeatherArt.by_popularity.limit(10) do
|
||||
column("作品") { |art| link_to(art.to_s, admin_weather_art_path(art)) }
|
||||
column("访问量") { |art| art.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
columns do
|
||||
column do
|
||||
panel "最冷门活跃城市" do
|
||||
table_for City.least_popular_active.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
# column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
column("操作") { |city|
|
||||
button_to "停用",
|
||||
admin_ahoy_dashboard_toggle_city_status_path(city_id: city.id),
|
||||
method: :post,
|
||||
data: { confirm: "确定要停用 #{city.name} 吗?" }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门未活跃城市" do
|
||||
table_for City.most_popular_inactive.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
column("所属区域") { |city| city.country.region.name }
|
||||
column("操作") { |city|
|
||||
button_to "激活",
|
||||
admin_ahoy_dashboard_toggle_city_status_path(city_id: city.id),
|
||||
method: :post,
|
||||
data: { confirm: "确定要激活 #{city.name} 吗?" }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 添加一个事件列表面板
|
||||
panel "最近事件" do
|
||||
table_for Ahoy::Event.order(time: :desc).limit(10) do
|
||||
column :time
|
||||
column :name
|
||||
column :properties
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
32
app/admin/ahoy_events.rb
Normal file
32
app/admin/ahoy_events.rb
Normal file
@ -0,0 +1,32 @@
|
||||
ActiveAdmin.register Ahoy::Event do
|
||||
menu label: "事件统计", parent: "数据统计"
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
# permit_params :visit_id, :user_id, :name, :properties, :time
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:visit_id, :user_id, :name, :properties, :time]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
# menu priority: 101, label: "事件统计"
|
||||
|
||||
actions :index
|
||||
|
||||
index do
|
||||
column :id
|
||||
column :name
|
||||
column :time
|
||||
column :properties
|
||||
column :user_id
|
||||
end
|
||||
|
||||
filter :name
|
||||
filter :time
|
||||
filter :properties
|
||||
end
|
34
app/admin/ahoy_management.rb
Normal file
34
app/admin/ahoy_management.rb
Normal file
@ -0,0 +1,34 @@
|
||||
# app/admin/ahoy_management.rb
|
||||
ActiveAdmin.register_page "Ahoy Management" do
|
||||
menu label: "访问数据管理", parent: "系统管理"
|
||||
|
||||
content title: "访问数据管理" do
|
||||
columns do
|
||||
column do
|
||||
panel "数据统计" do
|
||||
attributes_table_for :ahoy do
|
||||
row("总事件数") { Ahoy::Event.count }
|
||||
row("总访问数") { Ahoy::Visit.count }
|
||||
row("最早事件") { Ahoy::Event.minimum(:time)&.strftime("%Y-%m-%d %H:%M:%S") }
|
||||
row("最早访问") { Ahoy::Visit.minimum(:started_at)&.strftime("%Y-%m-%d %H:%M:%S") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "操作" do
|
||||
div class: "buttons" do
|
||||
button_to "立即清理旧数据", admin_ahoy_management_cleanup_path, method: :post,
|
||||
data: { confirm: "确定要清理3个月前的数据吗?" },
|
||||
class: "button"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
page_action :cleanup, method: :post do
|
||||
CleanAhoyDataWorker.perform_async
|
||||
redirect_to admin_ahoy_management_path, notice: "清理任务已加入队列"
|
||||
end
|
||||
end
|
36
app/admin/ahoy_visits.rb
Normal file
36
app/admin/ahoy_visits.rb
Normal file
@ -0,0 +1,36 @@
|
||||
ActiveAdmin.register Ahoy::Visit do
|
||||
menu label: "访客统计", parent: "数据统计"
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
# permit_params :visit_token, :visitor_token, :user_id, :ip, :user_agent, :referrer, :referring_domain, :landing_page, :browser, :os, :device_type, :country, :region, :city, :latitude, :longitude, :utm_source, :utm_medium, :utm_term, :utm_content, :utm_campaign, :app_version, :os_version, :platform, :started_at
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:visit_token, :visitor_token, :user_id, :ip, :user_agent, :referrer, :referring_domain, :landing_page, :browser, :os, :device_type, :country, :region, :city, :latitude, :longitude, :utm_source, :utm_medium, :utm_term, :utm_content, :utm_campaign, :app_version, :os_version, :platform, :started_at]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
|
||||
# menu priority: 100, label: "访问统计"
|
||||
|
||||
actions :index
|
||||
|
||||
index do
|
||||
column :id
|
||||
column :visitor_token
|
||||
column :ip
|
||||
column :user_agent
|
||||
column :started_at
|
||||
column :city
|
||||
column :country
|
||||
column :region
|
||||
end
|
||||
|
||||
filter :started_at
|
||||
filter :city
|
||||
filter :country
|
||||
end
|
@ -1,4 +1,5 @@
|
||||
ActiveAdmin.register City do
|
||||
menu label: "City Manager", parent: "系统管理"
|
||||
controller do
|
||||
def find_resource
|
||||
scoped_collection.friendly.find(params[:id])
|
||||
@ -36,18 +37,17 @@ ActiveAdmin.register City do
|
||||
|
||||
filter :name
|
||||
filter :active
|
||||
filter :country, as: :select
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :active
|
||||
f.input :name
|
||||
f.input :country
|
||||
f.input :latitude
|
||||
f.input :longitude
|
||||
f.input :priority
|
||||
f.input :timezone
|
||||
f.input :region
|
||||
f.input :last_weather_fetch
|
||||
f.input :last_image_generation
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
@ -1,4 +1,10 @@
|
||||
ActiveAdmin.register Country do
|
||||
menu label: "Country Manager", parent: "系统管理"
|
||||
controller do
|
||||
def find_resource
|
||||
scoped_collection.friendly.find(params[:id])
|
||||
end
|
||||
end
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
|
@ -11,6 +11,66 @@ ActiveAdmin.register_page "Dashboard" do
|
||||
end
|
||||
end
|
||||
|
||||
columns do
|
||||
column do
|
||||
panel "访问统计" do
|
||||
para "总访问量: #{Ahoy::Visit.count}"
|
||||
para "总事件数: #{Ahoy::Event.count}"
|
||||
para "独立访客数: #{Ahoy::Visit.distinct.count(:visitor_token)}"
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门城市" do
|
||||
table_for City.by_popularity.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门天气艺术" do
|
||||
table_for WeatherArt.by_popularity.limit(10) do
|
||||
column("作品") { |art| link_to(art.to_s, admin_weather_art_path(art)) }
|
||||
column("访问量") { |art| art.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
columns do
|
||||
column do
|
||||
panel "最冷门活跃城市" do
|
||||
table_for City.least_popular_active.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
# column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门未活跃城市" do
|
||||
table_for City.most_popular_inactive.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
column("所属区域") { |city| city.country.region.name }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 添加一个事件列表面板
|
||||
panel "最近事件" do
|
||||
table_for Ahoy::Event.order(time: :desc).limit(10) do
|
||||
column :time
|
||||
column :name
|
||||
column :properties
|
||||
end
|
||||
end
|
||||
|
||||
# Here is an example of a simple dashboard with columns and panels.
|
||||
#
|
||||
# columns do
|
||||
|
@ -1,4 +1,5 @@
|
||||
ActiveAdmin.register Region do
|
||||
menu label: "Region Manager", parent: "系统管理"
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
|
98
app/admin/sidekiq_jobs.rb
Normal file
98
app/admin/sidekiq_jobs.rb
Normal file
@ -0,0 +1,98 @@
|
||||
# app/admin/sidekiq_tasks.rb
|
||||
ActiveAdmin.register_page "Sidekiq Tasks" do
|
||||
# menu label: "Sidekiq Tasks", priority: 99
|
||||
menu label: "Sidekiq Tasks Manager", parent: "系统管理"
|
||||
|
||||
content title: "Sidekiq Tasks Management" do
|
||||
div class: "sidekiq-tasks" do
|
||||
panel "Manual Task Execution" do
|
||||
div class: "task-buttons" do
|
||||
div class: "task-button" do
|
||||
h3 "Generate Weather Arts"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "GenerateWeatherArtsWorker"
|
||||
select name: "city_id" do
|
||||
City.all.map do |city|
|
||||
option city.name, value: city.id
|
||||
end
|
||||
end
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
|
||||
div class: "task-button" do
|
||||
h3 "Batch Generate Weather Arts"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "BatchGenerateWeatherArts"
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
|
||||
div class: "task-button" do
|
||||
h3 "Refresh Sitemap"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "RefreshSitemapWorker"
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
|
||||
div class: "task-button" do
|
||||
h3 "Clean Ahoy Data"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "CleanAhoyDataWorker"
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
panel "Sidekiq Statistics" do
|
||||
stats = Sidekiq::Stats.new
|
||||
table class: "sidekiq-stats" do
|
||||
tr do
|
||||
th "Processed Jobs"
|
||||
td stats.processed
|
||||
end
|
||||
tr do
|
||||
th "Failed Jobs"
|
||||
td stats.failed
|
||||
end
|
||||
tr do
|
||||
th "Enqueued Jobs"
|
||||
td stats.enqueued
|
||||
end
|
||||
tr do
|
||||
th "Scheduled Jobs"
|
||||
td stats.scheduled_size
|
||||
end
|
||||
tr do
|
||||
th "Retry Set Size"
|
||||
td stats.retry_size
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
page_action :run_task, method: :post do
|
||||
task_name = params[:task]
|
||||
city_id = params[:city_id]
|
||||
|
||||
case task_name
|
||||
when "BatchGenerateWeatherArts"
|
||||
BatchGenerateWeatherArtsWorker.perform_async
|
||||
when "GenerateWeatherArtsWorker"
|
||||
GenerateWeatherArtWorker.perform_async(city_id)
|
||||
when "RefreshSitemapWorker"
|
||||
RefreshSitemapWorker.perform_async
|
||||
when "CleanAhoyDataWorker"
|
||||
CleanAhoyDataWorker.perform_async
|
||||
end
|
||||
|
||||
redirect_to admin_sidekiq_tasks_path, notice: "Task #{task_name} has been queued"
|
||||
end
|
||||
end
|
@ -1,4 +1,5 @@
|
||||
ActiveAdmin.register WeatherArt do
|
||||
menu label: "WeatherArt Manager", parent: "系统管理"
|
||||
controller do
|
||||
def find_resource
|
||||
scoped_collection.friendly.find(params[:id])
|
||||
|
BIN
app/assets/images/today_ai_weather_copyright_watermark1.png
Normal file
BIN
app/assets/images/today_ai_weather_copyright_watermark1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.9 KiB |
@ -1,2 +0,0 @@
|
||||
//= require active_admin/base
|
||||
import "@activeadmin/activeadmin";
|
@ -1,3 +1,4 @@
|
||||
@import "photoswipe/dist/photoswipe.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
24
app/concerns/seo_concern.rb
Normal file
24
app/concerns/seo_concern.rb
Normal file
@ -0,0 +1,24 @@
|
||||
# app/concerns/seo_concern.rb
|
||||
module SeoConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :prepare_meta_tags
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_meta_tags
|
||||
set_meta_tags(
|
||||
site: "TodayAIWeather",
|
||||
description: "Discover AI-generated weather art from cities around the world. Real-time weather visualization through artificial intelligence.",
|
||||
keywords: "weather, AI art, weather visualization, city weather, artificial intelligence",
|
||||
og: {
|
||||
title: :title,
|
||||
description: :description,
|
||||
type: "website",
|
||||
url: request.original_url
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
@ -1,7 +1,70 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include SeoConcern
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
before_action :log_browser_info
|
||||
# allow_browser versions: :modern
|
||||
# allow_browser versions: :modern,
|
||||
# patterns: [
|
||||
# # 鸿蒙系统相关
|
||||
# /OpenHarmony/, # 鸿蒙系统标识
|
||||
# /ArkWeb\/[\d.]+/, # 鸿蒙浏览器内核
|
||||
# /Mobile HuaweiBrowser/, # 华为浏览器(新格式)
|
||||
# /HuaweiBrowser\/[\d.]+/, # 华为浏览器(旧格式)
|
||||
#
|
||||
# # 夸克浏览器(更宽松的匹配)
|
||||
# /Quark[\s\/][\d.]+/, # 匹配 "Quark/7.4.6.681" 或 "Quark 7.4.6.681"
|
||||
#
|
||||
# /Mobile Safari/,
|
||||
# /Chrome\/[\d.]+/,
|
||||
# /Quark\/[\d.]+/,
|
||||
# /HuaweiBrowser\/[\d.]+/,
|
||||
# /MiuiBrowser\/[\d.]+/,
|
||||
# /VivoBrowser\/[\d.]+/,
|
||||
# /OppoBrowser\/[\d.]+/,
|
||||
# /UCBrowser\/[\d.]+/,
|
||||
# /QQBrowser\/[\d.]+/,
|
||||
# /MicroMessenger\/[\d.]+/,
|
||||
# /Alipay/,
|
||||
# /BaiduBoxApp/,
|
||||
# /baiduboxapp/i,
|
||||
# /SogouMobile/,
|
||||
# /Weibo/,
|
||||
# /DingTalk/,
|
||||
# /ToutiaoMicroApp/,
|
||||
# /BytedanceWebview/,
|
||||
# /ArkWeb/
|
||||
# ],
|
||||
# on_failure: ->(browser) {
|
||||
# Rails.logger.warn <<~BROWSER_INFO
|
||||
# Browser Blocked:
|
||||
# User Agent: #{browser.ua}
|
||||
# Name: #{browser.name}
|
||||
# Version: #{browser.version}
|
||||
# Platform: #{browser.platform.name}
|
||||
# Device: #{browser.device.name}
|
||||
# Mobile: #{browser.mobile?}
|
||||
# Modern: #{browser.modern?}
|
||||
# Bot: #{browser.bot?}
|
||||
# BROWSER_INFO
|
||||
# }
|
||||
before_action :set_locale
|
||||
after_action :track_action
|
||||
|
||||
def log_browser_info
|
||||
# 构建详细的浏览器信息
|
||||
Rails.logger.debug "User Agent: #{request.user_agent}"
|
||||
|
||||
# 如果是被拦截的浏览器,记录额外信息
|
||||
# unless browser_allowed?
|
||||
# Rails.logger.info "User Agent: #{request.user_agent}"
|
||||
# end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def track_action
|
||||
ahoy.track "Viewed Application", request.path_parameters
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
|
21
app/controllers/arts_controller.rb
Normal file
21
app/controllers/arts_controller.rb
Normal file
@ -0,0 +1,21 @@
|
||||
class ArtsController < ApplicationController
|
||||
def index
|
||||
@regions = Region.all
|
||||
@current_region = Region.find(params[:region]) if params[:region].present?
|
||||
|
||||
@weather_arts = WeatherArt.includes(city: [ :country, { country: :region } ])
|
||||
|
||||
if @current_region
|
||||
@weather_arts = @weather_arts.joins(city: :country)
|
||||
.where(countries: { region_id: @current_region.id })
|
||||
end
|
||||
|
||||
@weather_arts = if params[:sort] == "oldest"
|
||||
@weather_arts.order(created_at: :asc)
|
||||
else
|
||||
@weather_arts.order(created_at: :desc)
|
||||
end
|
||||
|
||||
@weather_arts = @weather_arts.page(params[:page]).per(10)
|
||||
end
|
||||
end
|
@ -1,8 +1,7 @@
|
||||
class CitiesController < ApplicationController
|
||||
def index
|
||||
@cities = City.all.order(:name)
|
||||
@regions = Region.includes(:countries).order(:name)
|
||||
@cities = City.includes(:country, country: :region).active.order(:name)
|
||||
@cities = City.includes(:country, country: :region).order(:name)
|
||||
|
||||
if params[:region]
|
||||
@current_region = Region.friendly.find(params[:region])
|
||||
@ -13,9 +12,31 @@ class CitiesController < ApplicationController
|
||||
@current_country = Country.friendly.find(params[:country])
|
||||
@cities = @cities.by_country(@current_country.id)
|
||||
end
|
||||
|
||||
@cities = @cities.page(params[:page]).per(10)
|
||||
|
||||
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"
|
||||
)
|
||||
end
|
||||
|
||||
def show
|
||||
@city = City.friendly.find(params[:id])
|
||||
ahoy.track "View City", {
|
||||
city_id: @city.id,
|
||||
name: @city.name,
|
||||
event_type: "city_view"
|
||||
}
|
||||
|
||||
set_meta_tags(
|
||||
title: @city.name,
|
||||
description: "Experience #{@city.name}'s weather through AI-generated art. Daily updates of weather conditions visualized through artificial intelligence.",
|
||||
keywords: "#{@city.name}, #{@city.country.name}, weather art, AI visualization",
|
||||
og: {
|
||||
image: @city.latest_weather_art&.image&.attached? ? url_for(@city.latest_weather_art.image) : nil
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -1,6 +1,11 @@
|
||||
class HomeController < ApplicationController
|
||||
def index
|
||||
@latest_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(6)
|
||||
@latest_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(20).shuffle.last(10)
|
||||
@featured_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(5)
|
||||
set_meta_tags(
|
||||
title: "AI-Generated Weather Art",
|
||||
description: "Experience weather through artistic AI visualization. Daily updated weather art for cities worldwide.",
|
||||
keywords: "AI weather art, weather visualization, city weather, artificial intelligence"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
33
app/controllers/sitemaps_controller.rb
Normal file
33
app/controllers/sitemaps_controller.rb
Normal file
@ -0,0 +1,33 @@
|
||||
class SitemapsController < ApplicationController
|
||||
def show
|
||||
path = params[:path]
|
||||
bucket_name =
|
||||
Rails.env.production? ?
|
||||
ENV.fetch("AWS_BUCKET", Rails.application.credentials.dig(:aws, :bucket)) :
|
||||
ENV.fetch("AWS_DEV_BUCKET", Rails.application.credentials.dig(:aws_dev, :bucket))
|
||||
Rails.logger.info "Sitemap: #{path}"
|
||||
|
||||
begin
|
||||
s3_client = Aws::S3::Client.new
|
||||
response = s3_client.get_object(
|
||||
bucket: bucket_name,
|
||||
key: "sitemaps/#{path}"
|
||||
)
|
||||
|
||||
expires_in 12.hours, public: true
|
||||
content_type = response.content_type || "application/xml"
|
||||
|
||||
send_data(
|
||||
response.body.read,
|
||||
filename: path,
|
||||
type: content_type,
|
||||
disposition: "inline"
|
||||
)
|
||||
rescue Aws::S3::Errors::NoSuchKey
|
||||
render status: :not_found
|
||||
rescue Aws::S3::Errors::ServiceError => e
|
||||
Rails.logger.error "S3 Error: #{e.message}"
|
||||
render status: :internal_server_error
|
||||
end
|
||||
end
|
||||
end
|
@ -2,5 +2,35 @@ class WeatherArtsController < ApplicationController
|
||||
def show
|
||||
@city = City.friendly.find(params[:city_id])
|
||||
@weather_art = @city.weather_arts.friendly.find(params[:slug])
|
||||
|
||||
@previous_weather_art = @city.weather_arts
|
||||
.where("id < ?", @weather_art.id)
|
||||
.order(id: :desc)
|
||||
.first
|
||||
|
||||
@next_weather_art = @city.weather_arts
|
||||
.where("id > ?", @weather_art.id)
|
||||
.order(id: :asc)
|
||||
.first
|
||||
|
||||
ahoy.track "View Weather Art", {
|
||||
weather_art_id: @weather_art.id,
|
||||
city_id: @weather_art.city_id,
|
||||
event_type: "weather_art_view"
|
||||
}
|
||||
ahoy.track "View City", {
|
||||
city_id: @city.id,
|
||||
name: @city.name,
|
||||
event_type: "city_view"
|
||||
}
|
||||
|
||||
set_meta_tags(
|
||||
title: "#{@city.name} Weather Art - #{@weather_art.weather_date.strftime('%B %d, %Y')}",
|
||||
description: "#{@city.name}'s weather visualized through AI art. #{@weather_art.description} at #{@weather_art.temperature}°C.",
|
||||
keywords: "#{@city.name}, weather art, #{@weather_art.description}, AI visualization",
|
||||
og: {
|
||||
image: @weather_art.image.attached? ? url_for(@weather_art.image) : nil
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -1,2 +1,24 @@
|
||||
module ApplicationHelper
|
||||
def weather_art_schema(weather_art)
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ImageObject",
|
||||
"name": "#{weather_art.city.name} Weather Art",
|
||||
"description": weather_art.description,
|
||||
"datePublished": weather_art.created_at.iso8601,
|
||||
"contentUrl": url_for(weather_art.image),
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "TodayAIWeather"
|
||||
},
|
||||
"locationCreated": {
|
||||
"@type": "Place",
|
||||
"name": weather_art.city.name,
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressCountry": weather_art.city.country.name
|
||||
}
|
||||
}
|
||||
}.to_json.html_safe if weather_art.image.attached?
|
||||
end
|
||||
end
|
||||
|
2
app/helpers/arts_helper.rb
Normal file
2
app/helpers/arts_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module ArtsHelper
|
||||
end
|
2
app/helpers/sitemaps_helper.rb
Normal file
2
app/helpers/sitemaps_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module SitemapsHelper
|
||||
end
|
@ -1,2 +1,51 @@
|
||||
module WeatherArtsHelper
|
||||
def weather_description_icon(description)
|
||||
case description&.downcase
|
||||
when /rain/
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>'.html_safe
|
||||
when /cloud/
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>'.html_safe
|
||||
when /sun|clear/
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>'.html_safe
|
||||
else
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>'.html_safe
|
||||
end
|
||||
end
|
||||
def weather_stat_icon(type)
|
||||
case type
|
||||
when "temperature"
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>'.html_safe
|
||||
when "wind"
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>'.html_safe
|
||||
when "humidity"
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>'.html_safe
|
||||
when "visibility"
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>'.html_safe
|
||||
when "pressure"
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>'.html_safe
|
||||
when "cloud"
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>'.html_safe
|
||||
end
|
||||
end
|
||||
end
|
||||
|
6
app/javascript/active_admin.js
Normal file
6
app/javascript/active_admin.js
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
import './add_jquery'
|
||||
import "jquery/dist/jquery"
|
||||
import "jquery-ui/dist/jquery-ui"
|
||||
import "jquery-ujs"
|
||||
import "@activeadmin/activeadmin"
|
4
app/javascript/add_jquery.js
Normal file
4
app/javascript/add_jquery.js
Normal file
@ -0,0 +1,4 @@
|
||||
import jquery from 'jquery'
|
||||
import $ from 'jquery'
|
||||
window.jQuery = jquery
|
||||
window.$ = $
|
@ -1,8 +1,10 @@
|
||||
// Entry point for the build script in your package.json
|
||||
import "@hotwired/turbo-rails"
|
||||
import "@hotwired/stimulus"
|
||||
import "@fontsource/playfair-display/400.css";
|
||||
import "@fontsource/playfair-display/700.css";
|
||||
import "@fontsource/raleway/400.css";
|
||||
import "@fontsource/raleway/600.css";
|
||||
|
||||
import "./controllers"
|
||||
import "./active_admin"
|
||||
|
@ -6,3 +6,9 @@ import { application } from "./application"
|
||||
|
||||
import HelloController from "./hello_controller"
|
||||
application.register("hello", HelloController)
|
||||
|
||||
import PhotoSwipeLightBoxController from "./photo_swipe_lightbox_controller"
|
||||
|
||||
console.log("ready to register photo-swipe")
|
||||
application.register("photo-swipe-lightbox", PhotoSwipeLightBoxController)
|
||||
console.log("successful to register photo-swipe")
|
||||
|
@ -0,0 +1,42 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import PhotoSwipeLightbox from 'photoswipe/lightbox'
|
||||
import PhotoSwipe from 'photoswipe'
|
||||
import 'photoswipe/dist/photoswipe.css'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['image', 'gallery']
|
||||
|
||||
connect() {
|
||||
this.initPhotoSwipeLightbox()
|
||||
}
|
||||
|
||||
initPhotoSwipeLightbox() {
|
||||
const lightbox = new PhotoSwipeLightbox({
|
||||
gallery: this.galleryTarget,
|
||||
children: 'a',
|
||||
pswpModule: PhotoSwipe,
|
||||
initialZoomInEndEvent: 'mousedown',
|
||||
dataSource: (items) => {
|
||||
return items.map((item) => ({
|
||||
src: item.dataset.pswpSrc,
|
||||
w: parseInt(item.dataset.pswpWidth, 10),
|
||||
h: parseInt(item.dataset.pswpHeight, 10),
|
||||
title: item.dataset.pswpCaption,
|
||||
}))
|
||||
},
|
||||
padding: { top: 0, bottom: 0, left: 0, right: 0 }, // 自定义图片与页面边界的填充
|
||||
closeOnScroll: false,
|
||||
zoom: true, // 启用缩放功能
|
||||
bgOpacity: 0.9, // 背景透明度
|
||||
pswpUIOptions: {
|
||||
arrowPrev: true,
|
||||
arrowNext: true,
|
||||
zoom: true, // 添加缩放按钮
|
||||
fullscreen: true, // 添加全屏按钮
|
||||
counter: true, // 显示当前图片编号
|
||||
}
|
||||
})
|
||||
lightbox.init()
|
||||
// console.log('PhotoSwipeLightbox instance:', lightbox);
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
@ -1,33 +0,0 @@
|
||||
class BatchGenerateWeatherArtsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(*args)
|
||||
start_time = Time.current
|
||||
max_duration = 50.minutes
|
||||
|
||||
cities_to_process = get_eligible_cities
|
||||
|
||||
cities_to_process.each do |city|
|
||||
break if Time.current - start_time > max_duration
|
||||
|
||||
GenerateWeatherArtJob.perform_now(city)
|
||||
sleep 1.minute # 确保不超过API限制
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_eligible_cities
|
||||
City.active
|
||||
.where(active: true)
|
||||
.where("last_weather_fetch IS NULL OR last_weather_fetch < ?", Date.today)
|
||||
# .select { |city| early_morning_in_timezone?(city.timezone) }
|
||||
end
|
||||
|
||||
# def early_morning_in_timezone?(timezone)
|
||||
# return false if timezone.blank?
|
||||
|
||||
# time = Time.current.in_time_zone(timezone)
|
||||
# time.hour == 2
|
||||
# end
|
||||
end
|
@ -1,45 +0,0 @@
|
||||
class GenerateWeatherArtJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(*args)
|
||||
city = args[0]
|
||||
return if city.last_weather_fetch&.today?
|
||||
|
||||
weather_service = WeatherService.new
|
||||
ai_service = AiService.new
|
||||
|
||||
# 获取天气数据
|
||||
weather_data = weather_service.get_weather(city.latitude, city.longitude)
|
||||
return unless weather_data
|
||||
|
||||
# 生成提示词
|
||||
prompt = ai_service.generate_prompt(city, weather_data)
|
||||
return unless prompt
|
||||
|
||||
# 生成图像
|
||||
image_url = ai_service.generate_image(prompt)
|
||||
return unless image_url
|
||||
|
||||
# 创建天气艺术记录
|
||||
weather_art = city.weather_arts.create!(
|
||||
weather_date: Date.today,
|
||||
**weather_data,
|
||||
prompt: prompt
|
||||
)
|
||||
|
||||
# 下载并附加图像
|
||||
tempfile = Down.download(image_url)
|
||||
weather_art.image.attach(
|
||||
io: tempfile,
|
||||
filename: "#{city.country.name}-#{city.name.parameterize}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.png"
|
||||
)
|
||||
|
||||
# 更新城市状态
|
||||
city.update!(
|
||||
last_weather_fetch: Time.current,
|
||||
last_image_generation: Time.current
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error "Error generating weather art for #{city.name}: #{e.message}"
|
||||
end
|
||||
end
|
14
app/models/ahoy/event.rb
Normal file
14
app/models/ahoy/event.rb
Normal file
@ -0,0 +1,14 @@
|
||||
class Ahoy::Event < ApplicationRecord
|
||||
# include Ahoy::QueryMethods
|
||||
|
||||
self.table_name = "ahoy_events"
|
||||
|
||||
belongs_to :visit
|
||||
belongs_to :user, optional: true
|
||||
|
||||
serialize :properties, coder: JSON
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "id", "id_value", "name", "properties", "time", "user_id", "visit_id" ]
|
||||
end
|
||||
end
|
10
app/models/ahoy/visit.rb
Normal file
10
app/models/ahoy/visit.rb
Normal file
@ -0,0 +1,10 @@
|
||||
class Ahoy::Visit < ApplicationRecord
|
||||
self.table_name = "ahoy_visits"
|
||||
|
||||
has_many :events, class_name: "Ahoy::Event"
|
||||
belongs_to :user, optional: true
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "app_version", "browser", "city", "country", "device_type", "id", "ip", "landing_page", "latitude", "longitude", "os", "os_version", "platform", "referrer", "referring_domain", "region", "started_at", "user_agent", "user_id", "utm_campaign", "utm_content", "utm_medium", "utm_source", "utm_term", "visit_token", "visitor_token" ]
|
||||
end
|
||||
end
|
@ -5,6 +5,11 @@ class City < ApplicationRecord
|
||||
|
||||
has_many :weather_arts, dependent: :destroy
|
||||
|
||||
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :city_id
|
||||
has_many :events, class_name: "Ahoy::Event", foreign_key: :city_id
|
||||
|
||||
delegate :region, to: :country
|
||||
|
||||
validates :name, presence: true
|
||||
validates :latitude, presence: true
|
||||
validates :longitude, presence: true
|
||||
@ -15,6 +20,58 @@ class City < ApplicationRecord
|
||||
scope :by_country, ->(country_id) { where(country_id: country_id) }
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
scope :by_popularity, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.city_id') = cities.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
else
|
||||
joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'city_id')::integer = cities.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
end
|
||||
}
|
||||
|
||||
scope :least_popular_active, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
active
|
||||
.joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.city_id') = cities.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count ASC, cities.name ASC")
|
||||
else
|
||||
active
|
||||
.joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'city_id')::integer = cities.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count ASC, cities.name ASC")
|
||||
end
|
||||
}
|
||||
scope :most_popular_inactive, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
where(active: false)
|
||||
.joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.city_id') = cities.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("COUNT(ahoy_events.id) DESC, cities.name ASC")
|
||||
else
|
||||
where(active: false)
|
||||
.joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'city_id')::integer = cities.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("COUNT(ahoy_events.id) DESC, cities.name ASC")
|
||||
end
|
||||
}
|
||||
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
@ -27,7 +84,7 @@ class City < ApplicationRecord
|
||||
end
|
||||
|
||||
def localized_name
|
||||
I18n.t("cities.#{name.parameterize.underscore}")
|
||||
I18n.t("cities.#{name.parameterize.underscore}", default: name)
|
||||
end
|
||||
|
||||
def full_name
|
||||
@ -43,10 +100,32 @@ class City < ApplicationRecord
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "active", "country", "created_at", "id", "id_value", "last_image_generation", "last_weather_fetch", "latitude", "longitude", "name", "priority", "region", "slug", "timezone", "updated_at" ]
|
||||
[ "active", "country_id", "created_at", "id", "id_value", "last_image_generation", "last_weather_fetch", "latitude", "longitude", "name", "priority", "region", "slug", "timezone", "updated_at" ]
|
||||
end
|
||||
|
||||
def last_weather_fetch
|
||||
# latest_weather_art&.created_at
|
||||
Rails.cache.fetch("city/#{id}/last_weather_fetch", expires_in: 1.hour) do
|
||||
latest_weather_art&.created_at
|
||||
end
|
||||
end
|
||||
|
||||
def last_image_generation
|
||||
# latest_weather_art&.image&.created_at
|
||||
Rails.cache.fetch("city/#{id}/last_image_generation", expires_in: 1.hour) do
|
||||
latest_weather_art&.image&.created_at
|
||||
end
|
||||
end
|
||||
|
||||
def latest_weather_art
|
||||
weather_arts.order(weather_date: :desc).first
|
||||
end
|
||||
|
||||
def view_count
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
Ahoy::Event.where("json_extract(properties, '$.event_type') = 'city_view' AND json_extract(properties, '$.city_id') = ?", self.id).count
|
||||
else
|
||||
Ahoy::Event.where("properties::jsonb->>'event_type' = 'city_view' AND (properties::jsonb->>'city_id')::integer = ?", self.id).count
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -4,10 +4,30 @@ class WeatherArt < ApplicationRecord
|
||||
|
||||
belongs_to :city
|
||||
has_one_attached :image
|
||||
has_one_attached :image_with_watermark
|
||||
|
||||
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :weather_art_id
|
||||
has_many :events, class_name: "Ahoy::Event", foreign_key: :weather_art_id
|
||||
|
||||
validates :weather_date, presence: true
|
||||
validates :city_id, presence: true
|
||||
|
||||
scope :by_popularity, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.weather_art_id') = weather_arts.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'weather_art_view'")
|
||||
.group("weather_arts.id")
|
||||
.select("weather_arts.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
else
|
||||
joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'weather_art_id')::integer = weather_arts.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'weather_art_view'")
|
||||
.group("weather_arts.id")
|
||||
.select("weather_arts.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
end
|
||||
}
|
||||
|
||||
def should_generate_new_friendly_id?
|
||||
weather_date_changed? || city_id_changed? || super
|
||||
end
|
||||
@ -23,4 +43,16 @@ class WeatherArt < ApplicationRecord
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "city_id", "cloud", "created_at", "description", "feeling_temp", "humidity", "id", "id_value", "precipitation", "pressure", "prompt", "temperature", "updated_at", "visibility", "weather_date", "wind_scale", "wind_speed" ]
|
||||
end
|
||||
|
||||
def view_count
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
Ahoy::Event.where("json_extract(properties, '$.event_type') = 'weather_art_view' AND json_extract(properties, '$.weather_art_id') = ?", self.id).count
|
||||
else
|
||||
Ahoy::Event.where("properties::jsonb->>'event_type' = 'weather_art_view' AND (properties::jsonb->>'weather_art_id')::integer = ?", self.id).count
|
||||
end
|
||||
end
|
||||
|
||||
def image_url
|
||||
image.attached? ? image.blob : nil
|
||||
end
|
||||
end
|
||||
|
@ -50,7 +50,7 @@ class AiService
|
||||
- Temperature: #{weather_data[:temperature]}°C
|
||||
- Weather: #{weather_data[:description]}
|
||||
- Cloud cover: #{weather_data[:cloud]}%
|
||||
- Time: Early morning
|
||||
- Time: #{weather_data[:time]}
|
||||
|
||||
Requirements:
|
||||
- Feature iconic landmarks or architecture from #{city.name}
|
||||
|
@ -31,7 +31,8 @@ class WeatherService
|
||||
pressure: data["pressure"].to_f,
|
||||
visibility: data["vis"].to_f,
|
||||
cloud: data["cloud"].to_f,
|
||||
description: data["text"]
|
||||
description: data["text"],
|
||||
time: response["updateTime"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
172
app/views/arts/index.html.erb
Normal file
172
app/views/arts/index.html.erb
Normal file
@ -0,0 +1,172 @@
|
||||
<!-- app/views/arts/index.html.erb -->
|
||||
<div class="min-h-screen">
|
||||
<!-- 页面标题和背景 -->
|
||||
<% featured_art = @weather_arts.first %>
|
||||
<div class="relative">
|
||||
<!-- 背景图像 -->
|
||||
<% if featured_art&.image&.attached? %>
|
||||
<div class="absolute inset-0 h-[40vh] overflow-hidden">
|
||||
<%= image_tag featured_art.image,
|
||||
class: "w-full h-full object-cover" %>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-base-100/30 via-base-100/60 to-base-100"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- 标题内容 -->
|
||||
<div class="relative pt-20 pb-32">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-3xl mx-auto text-center space-y-6">
|
||||
<h1 class="text-4xl md:text-5xl font-display font-bold">
|
||||
Weather Arts Gallery
|
||||
</h1>
|
||||
<p class="text-xl text-base-content/70">
|
||||
Discover AI-generated weather art from cities around the world
|
||||
</p>
|
||||
|
||||
<!-- 如果有特色图片,显示其信息 -->
|
||||
<% if featured_art %>
|
||||
<div class="text-sm text-base-content/60 pt-4">
|
||||
Latest from <%= featured_art.city.name %>, <%= featured_art.city.country.name %>
|
||||
<span class="mx-2">•</span>
|
||||
<%= featured_art.weather_date.strftime("%B %d, %Y") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选导航 -->
|
||||
<div class="container mx-auto px-4 -mt-8">
|
||||
<div class="bg-base-100 shadow-xl rounded-box p-6 mb-12">
|
||||
<!-- 筛选选项 -->
|
||||
<div class="flex flex-wrap gap-4 justify-center items-center">
|
||||
<!-- 时间排序 -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<%= params[:sort] == 'oldest' ? 'Oldest First' : 'Newest First' %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<%= link_to "Newest First", arts_path(sort: 'newest', region: params[:region]),
|
||||
class: "#{'active' if params[:sort] != 'oldest'}" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to "Oldest First", arts_path(sort: 'oldest', region: params[:region]),
|
||||
class: "#{'active' if params[:sort] == 'oldest'}" %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 区域筛选 -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<%= @current_region&.name || 'All Regions' %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<%= link_to "All Regions", arts_path(sort: params[:sort]),
|
||||
class: "#{'active' unless @current_region}" %>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<% @regions.each do |region| %>
|
||||
<li>
|
||||
<%= link_to region.name, arts_path(region: region.id, sort: params[:sort]),
|
||||
class: "#{'active' if @current_region == region}" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果统计 -->
|
||||
<div class="text-center text-sm text-base-content/70 mt-4">
|
||||
Showing <%= @weather_arts.total_count %> weather arts
|
||||
<% if @current_region %>
|
||||
from <%= @current_region.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<% @weather_arts.each do |art| %>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 group overflow-hidden">
|
||||
<figure class="relative aspect-square overflow-hidden">
|
||||
<% if art.image.attached? %>
|
||||
<%= image_tag art.image,
|
||||
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<div class="absolute inset-0 p-6 flex flex-col justify-end translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<div class="text-white space-y-2">
|
||||
<h3 class="text-xl font-display font-bold">
|
||||
<%= art.city.name %>
|
||||
</h3>
|
||||
<p class="text-sm text-white/80">
|
||||
<%= art.city.country.name %>
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-white/90">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
<%= art.description %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</figure>
|
||||
|
||||
<!-- 信息部分 -->
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 class="font-display font-bold leading-tight">
|
||||
<%= art.city.name %>
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
<%= art.weather_date.strftime("%B %d, %Y") %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
<%= art.temperature %>°C
|
||||
</div>
|
||||
<div class="text-sm text-base-content/70">
|
||||
<%= art.humidity %>% humidity
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= link_to city_weather_art_path(art.city, art),
|
||||
class: "btn btn-primary btn-sm w-full" do %>
|
||||
View Details
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/pagination',
|
||||
collection: @weather_arts,
|
||||
collection_name: 'weather arts' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -12,7 +12,6 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- 标题内容 -->
|
||||
<div class="relative pt-24 pb-32">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-3xl mx-auto text-center space-y-6">
|
||||
@ -38,13 +37,10 @@
|
||||
</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">
|
||||
<!-- 左侧筛选器 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 区域选择下拉框 -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@ -73,7 +69,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 国家选择下拉框 (如果选择了区域) -->
|
||||
<% if @current_region %>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
@ -104,7 +99,6 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- 右侧结果统计 -->
|
||||
<div class="text-sm text-base-content/70">
|
||||
<%= @cities.count %> <%= 'city'.pluralize(@cities.count) %>
|
||||
<% if @current_country %>
|
||||
@ -117,10 +111,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 城市网格 -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<%= render partial: 'city', collection: @cities %>
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
<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' %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
@ -1,91 +1,149 @@
|
||||
<div class="min-h-screen">
|
||||
<!-- 城市头部信息 -->
|
||||
<section class="relative h-[40vh] overflow-hidden">
|
||||
<% if @city.latest_weather_art&.image&.attached? %>
|
||||
<div class="relative min-h-screen bg-base-200">
|
||||
<!-- 背景效果 -->
|
||||
<% if @city.latest_weather_art&.image&.attached? %>
|
||||
<div class="fixed inset-0 -z-10">
|
||||
<%= image_tag @city.latest_weather_art.image,
|
||||
class: "w-full h-full object-cover" %>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-base-100 via-base-100/50 to-transparent"></div>
|
||||
<% end %>
|
||||
class: "absolute w-full h-full object-cover scale-110 filter blur-2xl opacity-25" %>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-base-200/90 to-base-200/70 backdrop-blur-md"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-4xl">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<%= link_to cities_path,
|
||||
class: "btn btn-ghost btn-circle" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
<h1 class="text-4xl md:text-5xl font-display font-bold"><%= @city.localized_name %></h1>
|
||||
<!-- 主要内容 -->
|
||||
<div class="relative z-10">
|
||||
<!-- 返回导航 -->
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<%= link_to cities_path,
|
||||
class: "btn btn-ghost btn-lg gap-2 bg-base-100/50 backdrop-blur-sm hover:bg-base-100/70 transition-all duration-300" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Cities
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- 城市信息头部 -->
|
||||
<div class="container mx-auto px-4 mb-12">
|
||||
<div class="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h1 class="text-4xl md:text-6xl font-display font-bold">
|
||||
<span class="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
<%= @city.localized_name %>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap justify-center items-center gap-3">
|
||||
<div class="badge badge-lg badge-primary gap-2">
|
||||
<%= @city.country.name %>, <%= @city.region %>
|
||||
</div>
|
||||
<div class="badge badge-lg badge-secondary gap-2">
|
||||
<%= @city.timezone.present? ? Time.current.in_time_zone(@city.timezone).strftime("%Y-%m-%d %H:%M") : "Timezone undefined" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要统计信息 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-8">
|
||||
<div class="stat bg-base-100/80 backdrop-blur-sm shadow-lg rounded-box hover:bg-base-100/90 transition-all duration-300">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= weather_stat_icon("temperature") %>
|
||||
<div class="stat-title font-medium">Latest Weather</div>
|
||||
</div>
|
||||
<div class="stat-value text-2xl"><%= @city.latest_weather_art&.temperature %>°C</div>
|
||||
<div class="stat-desc mt-1"><%= @city.latest_weather_art&.description %></div>
|
||||
</div>
|
||||
|
||||
<div class="stats bg-base-100/80 backdrop-blur-sm shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Latitude</div>
|
||||
<div class="stat-value text-2xl"><%= @city.latitude %></div>
|
||||
<div class="stat bg-base-100/80 backdrop-blur-sm shadow-lg rounded-box hover:bg-base-100/90 transition-all duration-300">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= weather_stat_icon("location") %>
|
||||
<div class="stat-title font-medium">Coordinates</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Longitude</div>
|
||||
<div class="stat-value text-2xl"><%= @city.longitude %></div>
|
||||
<div class="stat-value text-xl">
|
||||
<%= @city.latitude %>°N,
|
||||
<%= @city.longitude %>°E
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Weather Arts</div>
|
||||
<div class="stat-value text-2xl"><%= @city.weather_arts.count %></div>
|
||||
<div class="stat-desc mt-1">Geographical Location</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100/80 backdrop-blur-sm shadow-lg rounded-box hover:bg-base-100/90 transition-all duration-300">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= weather_stat_icon("history") %>
|
||||
<div class="stat-title font-medium">Records</div>
|
||||
</div>
|
||||
<div class="stat-value text-2xl"><%= @city.weather_arts.count %></div>
|
||||
<div class="stat-desc mt-1">Total Weather Arts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 天气艺术历史记录 -->
|
||||
<section class="container mx-auto px-4 py-16">
|
||||
<div class="space-y-8">
|
||||
<h2 class="text-3xl font-display font-bold">Weather Art History</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<% @city.weather_arts.order(weather_date: :desc).each do |art| %>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||
<figure class="relative aspect-[4/3] overflow-hidden">
|
||||
<% if art.image.attached? %>
|
||||
<%= image_tag art.image,
|
||||
class: "w-full h-full object-cover transform hover:scale-105 transition-transform duration-500" %>
|
||||
<% end %>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/60 to-transparent">
|
||||
<div class="text-white">
|
||||
<div class="text-2xl font-bold"><%= art.temperature %>°C</div>
|
||||
<div class="text-sm opacity-90"><%= art.weather_date.strftime("%B %d, %Y") %></div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<div class="card-body">
|
||||
<h3 class="card-title font-display"><%= art.description %></h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 my-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
<span>Humidity: <%= art.humidity %>%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<span>Wind: <%= art.wind_scale %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<%= link_to "View Details", city_weather_art_path(@city, art),
|
||||
class: "btn btn-primary btn-outline" %>
|
||||
</div>
|
||||
<!-- 天气艺术历史记录 -->
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
<div class="max-w-7xl mx-auto space-y-8">
|
||||
<!-- 标题和更新时间 -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<h2 class="text-3xl font-display font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Weather Art History
|
||||
</h2>
|
||||
<div class="card bg-base-100/80 backdrop-blur-sm shadow-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="font-medium">Last Updated:</span>
|
||||
<span class="text-base-content/70">
|
||||
<%= time_ago_in_words(@city.last_weather_fetch) if @city.last_weather_fetch %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- 天气艺术卡片网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @city.weather_arts.order(weather_date: :desc).each do |art| %>
|
||||
<div class="card bg-base-100/80 backdrop-blur-sm shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<figure class="relative aspect-video overflow-hidden">
|
||||
<% if art.image.attached? %>
|
||||
<%= image_tag art.image,
|
||||
class: "w-full h-full object-cover transform hover:scale-105 transition-transform duration-500" %>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/70 to-transparent">
|
||||
<div class="flex items-center justify-between text-white">
|
||||
<div class="text-2xl font-bold"><%= art.temperature %>°C</div>
|
||||
<div class="text-right">
|
||||
<div class="font-medium"><%= art.weather_date.strftime("%H:%M") %></div>
|
||||
<div class="text-sm opacity-80"><%= art.weather_date.strftime("%B %d, %Y") %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</figure>
|
||||
|
||||
<div class="card-body">
|
||||
<h3 class="card-title font-display">
|
||||
<%= weather_description_icon(art.description) %>
|
||||
<%= art.description %>
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 my-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<%= weather_stat_icon("humidity") %>
|
||||
<span>Humidity: <%= art.humidity %>%</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<%= weather_stat_icon("wind") %>
|
||||
<span>Wind: <%= art.wind_scale %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= link_to city_weather_art_path(@city, art),
|
||||
class: "btn btn-primary btn-block" do %>
|
||||
View Details
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
@ -25,7 +25,7 @@
|
||||
|
||||
<!-- 最新天气艺术 -->
|
||||
<section class="container mx-auto px-4 py-16 space-y-12">
|
||||
<h2 class="text-3xl font-display font-bold text-center">Latest Weather Art</h2>
|
||||
<h2 class="text-3xl font-display font-bold text-center">Shuffle Latest Weather Art</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<% @latest_arts.each do |art| %>
|
||||
@ -57,4 +57,12 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="text-center mt-12 mb-12">
|
||||
<%= link_to arts_path, class: "btn btn-primary btn-lg gap-2" do %>
|
||||
View All Weather Arts
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
11
app/views/kaminari/_first_page.html.erb
Normal file
11
app/views/kaminari/_first_page.html.erb
Normal file
@ -0,0 +1,11 @@
|
||||
<%# Link to the "First" page
|
||||
- available local variables
|
||||
url: url to the first page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="first">
|
||||
<%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %>
|
||||
</span>
|
8
app/views/kaminari/_gap.html.erb
Normal file
8
app/views/kaminari/_gap.html.erb
Normal file
@ -0,0 +1,8 @@
|
||||
<%# Non-link tag that stands for skipped pages...
|
||||
- available local variables
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="page gap"><%= t('views.pagination.truncate').html_safe %></span>
|
11
app/views/kaminari/_last_page.html.erb
Normal file
11
app/views/kaminari/_last_page.html.erb
Normal file
@ -0,0 +1,11 @@
|
||||
<%# Link to the "Last" page
|
||||
- available local variables
|
||||
url: url to the last page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="last">
|
||||
<%= link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, remote: remote %>
|
||||
</span>
|
11
app/views/kaminari/_next_page.html.erb
Normal file
11
app/views/kaminari/_next_page.html.erb
Normal file
@ -0,0 +1,11 @@
|
||||
<%# Link to the "Next" page
|
||||
- available local variables
|
||||
url: url to the next page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="next">
|
||||
<%= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote %>
|
||||
</span>
|
12
app/views/kaminari/_page.html.erb
Normal file
12
app/views/kaminari/_page.html.erb
Normal file
@ -0,0 +1,12 @@
|
||||
<%# Link showing page number
|
||||
- available local variables
|
||||
page: a page object for "this" page
|
||||
url: url to this page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="page<%= ' current' if page.current? %>">
|
||||
<%= link_to_unless page.current?, page, url, {remote: remote, rel: page.rel} %>
|
||||
</span>
|
25
app/views/kaminari/_paginator.html.erb
Normal file
25
app/views/kaminari/_paginator.html.erb
Normal file
@ -0,0 +1,25 @@
|
||||
<%# The container tag
|
||||
- available local variables
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
paginator: the paginator that renders the pagination tags inside
|
||||
-%>
|
||||
<%= paginator.render do -%>
|
||||
<nav class="pagination" role="navigation" aria-label="pager">
|
||||
<%= first_page_tag unless current_page.first? %>
|
||||
<%= prev_page_tag unless current_page.first? %>
|
||||
<% each_page do |page| -%>
|
||||
<% if page.display_tag? -%>
|
||||
<%= page_tag page %>
|
||||
<% elsif !page.was_truncated? -%>
|
||||
<%= gap_tag %>
|
||||
<% end -%>
|
||||
<% end -%>
|
||||
<% unless current_page.out_of_range? %>
|
||||
<%= next_page_tag unless current_page.last? %>
|
||||
<%= last_page_tag unless current_page.last? %>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end -%>
|
11
app/views/kaminari/_prev_page.html.erb
Normal file
11
app/views/kaminari/_prev_page.html.erb
Normal file
@ -0,0 +1,11 @@
|
||||
<%# Link to the "Previous" page
|
||||
- available local variables
|
||||
url: url to the previous page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="prev">
|
||||
<%= link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote %>
|
||||
</span>
|
@ -1,10 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><%= content_for(:title) || "Today Ai Weather" %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<%= display_meta_tags(
|
||||
site: 'TodayAIWeather',
|
||||
reverse: true,
|
||||
og: {
|
||||
site_name: 'TodayAIWeather',
|
||||
type: 'website',
|
||||
url: request.original_url
|
||||
},
|
||||
alternate: {
|
||||
"zh-CN" => url_for(locale: 'zh-CN'),
|
||||
"en" => url_for(locale: 'en')
|
||||
}
|
||||
) %>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
@ -20,6 +33,23 @@
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<script defer data-domain="todayaiweather.com" src="https://plausible.frytea.com/js/script.js"></script>
|
||||
|
||||
<script defer src="https://busuanzi.frytea.com/js"></script>
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-PX1C92V5L7"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-PX1C92V5L7');
|
||||
</script>
|
||||
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-7296634171837358"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-base-100 font-sans">
|
||||
@ -28,11 +58,12 @@
|
||||
<div class="container mx-auto">
|
||||
<div class="flex-1">
|
||||
<%= link_to root_path, class: "text-2xl font-display font-bold hover:text-primary transition-colors" do %>
|
||||
AI Weather Art
|
||||
Today AI Weather
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<%= link_to "Cities", cities_path, class: "btn btn-ghost font-sans" %>
|
||||
<%= link_to "Arts", arts_path, class: "btn btn-ghost font-sans" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -43,10 +74,25 @@
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer footer-center p-8 bg-base-200 text-base-content mt-16">
|
||||
<div>
|
||||
<p class="font-display">Copyright © 2024 - All rights reserved by AI Weather Art</p>
|
||||
<footer class="footer footer-center p-8 bg-base-200 text-base-content">
|
||||
<div class="container mx-auto flex flex-col gap-2">
|
||||
<div id="busuanzi_container" class="text-xs opacity-70">
|
||||
<div class="space-x-2">
|
||||
<span>Page Views: <span id="busuanzi_page_pv"></span></span>
|
||||
<span>|</span>
|
||||
<span>Page Visitors: <span id="busuanzi_page_uv"></span></span>
|
||||
<span>|</span>
|
||||
<span>Total Views: <span id="busuanzi_site_pv"></span></span>
|
||||
<span>|</span>
|
||||
<span>Total Visitors: <span id="busuanzi_site_uv"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="font-display opacity-80">
|
||||
Copyright © 2024 - All rights reserved by AI Weather Art
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
86
app/views/shared/_pagination.html.erb
Normal file
86
app/views/shared/_pagination.html.erb
Normal file
@ -0,0 +1,86 @@
|
||||
<%# app/views/shared/_pagination.html.erb %>
|
||||
<% if collection.total_pages > 1 %>
|
||||
<div class="flex flex-col items-center mt-16 gap-6">
|
||||
<!-- 页码信息 -->
|
||||
<div class="text-base-content/70 font-light">
|
||||
<span class="px-4 py-2 bg-base-200/50 rounded-full">
|
||||
Page <%= collection.current_page %> of <%= collection.total_pages %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<div class="join shadow-lg">
|
||||
<!-- 首页 -->
|
||||
<%= link_to url_for(page: 1, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.first_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
|
||||
<!-- 上一页 -->
|
||||
<%= link_to url_for(page: collection.prev_page || 1, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.first_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
|
||||
<!-- 页码 -->
|
||||
<% page_window = 2 # 当前页面前后显示的页码数 %>
|
||||
<% start_page = [1, collection.current_page - page_window].max %>
|
||||
<% end_page = [collection.total_pages, collection.current_page + page_window].min %>
|
||||
|
||||
<% if start_page > 1 %>
|
||||
<%= link_to 1, url_for(page: 1, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs btn-ghost hover:bg-primary/5" %>
|
||||
<% if start_page > 2 %>
|
||||
<button class="join-item btn btn-xs btn-ghost btn-disabled">...</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% (start_page..end_page).each do |page| %>
|
||||
<% if page == collection.current_page %>
|
||||
<button class="join-item btn btn-xs btn-ghost bg-primary/10 font-medium">
|
||||
<%= page %>
|
||||
</button>
|
||||
<% else %>
|
||||
<%= link_to page, url_for(page: page, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs btn-ghost hover:bg-primary/5" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if end_page < collection.total_pages %>
|
||||
<% if end_page < collection.total_pages - 1 %>
|
||||
<button class="join-item btn btn-xs btn-ghost btn-disabled">...</button>
|
||||
<% end %>
|
||||
<%= link_to collection.total_pages,
|
||||
url_for(page: collection.total_pages, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs btn-ghost hover:bg-primary/5" %>
|
||||
<% end %>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<%= link_to url_for(page: collection.next_page || collection.total_pages, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.last_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
|
||||
<!-- 末页 -->
|
||||
<%= link_to url_for(page: collection.total_pages, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.last_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- 结果统计 -->
|
||||
<div class="text-sm text-base-content/60 font-light">
|
||||
Showing <%= collection.offset_value + 1 %> to
|
||||
<%= collection.last_page? ? collection.total_count : collection.offset_value + collection.limit_value %>
|
||||
of <%= collection.total_count %> <%= collection_name || 'items' %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
37
app/views/weather_arts/_weather_stats.html.erb
Normal file
37
app/views/weather_arts/_weather_stats.html.erb
Normal file
@ -0,0 +1,37 @@
|
||||
<%# Partial _weather_stats.html.erb %>
|
||||
|
||||
<div class="stat bg-gradient-to-br from-primary/10 to-primary/20 hover:from-primary hover:to-primary/30 p-4 rounded-lg">
|
||||
<div class="stat-title font-medium text-base">Temperature</div>
|
||||
<div class="stat-value text-3xl"><%= weather_art.temperature %>°C</div>
|
||||
<div class="stat-desc">Feels like <%= weather_art.feeling_temp %>°C</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-gradient-to-br from-secondary/10 to-secondary/20 hover:from-secondary hover:to-secondary/30 p-4 rounded-lg">
|
||||
<div class="stat-title font-medium text-base">Wind</div>
|
||||
<div class="stat-value text-3xl"><%= weather_art.wind_scale %></div>
|
||||
<div class="stat-desc"><%= weather_art.wind_speed %> km/h</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-300 hover:bg-base-400 p-4 rounded-lg">
|
||||
<div class="stat-title font-medium text-base">Humidity</div>
|
||||
<div class="stat-value text-3xl"><%= weather_art.humidity %>%</div>
|
||||
<div class="stat-desc">Relative humidity</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-300 hover:bg-base-400 p-4 rounded-lg">
|
||||
<div class="stat-title font-medium text-base">Visibility</div>
|
||||
<div class="stat-value text-3xl"><%= weather_art.visibility %> km</div>
|
||||
<div class="stat-desc">Clear view distance</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-accent/10 hover:bg-accent p-4 rounded-lg">
|
||||
<div class="stat-title font-medium text-base">Pressure</div>
|
||||
<div class="stat-value text-3xl"><%= weather_art.pressure %> hPa</div>
|
||||
<div class="stat-desc">Atmospheric pressure</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 hover:bg-base-100 p-4 rounded-lg">
|
||||
<div class="stat-title font-medium text-base">Cloud Cover</div>
|
||||
<div class="stat-value text-3xl"><%= weather_art.cloud %>%</div>
|
||||
<div class="stat-desc">Sky coverage</div>
|
||||
</div>
|
@ -1,84 +1,121 @@
|
||||
<div class="min-h-screen">
|
||||
<!-- 返回导航 -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<%= link_to city_path(@weather_art.city),
|
||||
class: "btn btn-ghost gap-2" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to <%= @weather_art.city.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% content_for :head do %>
|
||||
<script type="application/ld+json">
|
||||
<%= weather_art_schema(@weather_art) %>
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- 头部信息 -->
|
||||
<div class="text-center space-y-4 mb-12">
|
||||
<h1 class="text-4xl md:text-5xl font-display font-bold">
|
||||
<%= @weather_art.city.name %>
|
||||
</h1>
|
||||
<p class="text-xl text-base-content/70">
|
||||
<%= @weather_art.weather_date.strftime("%B %d, %Y") %>
|
||||
</p>
|
||||
<div class="relative min-h-screen bg-white"> <!-- 使用更明快的背景颜色 -->
|
||||
<div class="container mx-auto px-4 pt-12 pb-16">
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
|
||||
<!-- 返回导航 -->
|
||||
<div class="flex items-center">
|
||||
<%= link_to city_path(@weather_art.city),
|
||||
class: "btn btn-ghost btn-md gap-2 bg-base-200 hover:bg-base-300 transition-all duration-300" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to <%= @weather_art.city.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- 主要卡片 -->
|
||||
<div class="card lg:card-side bg-base-100 shadow-2xl">
|
||||
<figure class="lg:w-1/2 relative aspect-square lg:aspect-auto">
|
||||
<!-- 主要内容 -->
|
||||
<div class="card bg-base-200/80 backdrop-blur-md shadow-lg overflow-hidden"> <!-- 调整透明度和阴影 -->
|
||||
<div class="grid lg:grid-cols-2 gap-6 items-center">
|
||||
|
||||
<!-- 图片区域 -->
|
||||
<% if @weather_art.image.attached? %>
|
||||
<%= image_tag @weather_art.image,
|
||||
class: "w-full h-full object-cover" %>
|
||||
<figure class="relative lg:h-[30rem] h-auto overflow-hidden rounded-lg"> <!-- 添加圆角 -->
|
||||
<div class="gallery" data-controller="photo-swipe-lightbox">
|
||||
<div data-photo-swipe-lightbox-target="gallery" class="h-full">
|
||||
<% blob = @weather_art.image_blob %>
|
||||
<%= link_to rails_blob_path(blob),
|
||||
data: {
|
||||
pswp_src: rails_blob_url(blob),
|
||||
pswp_caption: 'Weather Art',
|
||||
pswp_width: 1792,
|
||||
pswp_height: 1024
|
||||
} do %>
|
||||
<%= image_tag @weather_art.image, class: "object-cover w-full h-full transition-transform transform hover:scale-105 ease-in-out" %>
|
||||
<%#= image_tag @weather_art.watermarked_variant.processed , class: "object-cover w-full h-full transition-transform transform hover:scale-105 ease-in-out" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
<% end %>
|
||||
</figure>
|
||||
|
||||
<div class="card-body lg:w-1/2">
|
||||
<h2 class="card-title font-display text-2xl mb-6">
|
||||
<%= @weather_art.description %>
|
||||
</h2>
|
||||
<!-- 信息区域 -->
|
||||
<div class="card-body p-8 lg:py-10 lg:px-12">
|
||||
<div class="prose max-w-none">
|
||||
<h1 class="font-display text-4xl md:text-5xl font-bold text-gradient mb-6">
|
||||
<%= @weather_art.city.full_name %> Weather Art
|
||||
</h1>
|
||||
|
||||
<!-- 天气数据网格 -->
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Temperature</div>
|
||||
<div class="stat-value"><%= @weather_art.temperature %>°C</div>
|
||||
<div class="stat-desc">Feels like <%= @weather_art.feeling_temp %>°C</div>
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<div class="badge badge-lg badge-primary">
|
||||
<%= @weather_art.weather_date.strftime("%B %d, %Y") %>
|
||||
</div>
|
||||
<div class="badge badge-lg badge-secondary">
|
||||
<%= @weather_art.weather_date.strftime("%H:%M") %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-semibold mb-4">
|
||||
<%= weather_description_icon(@weather_art.description) %>
|
||||
<%= @weather_art.description %>
|
||||
</h2>
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= render 'weather_stats', weather_art: @weather_art %> <!-- 使用局部渲染 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Wind</div>
|
||||
<div class="stat-value"><%= @weather_art.wind_scale %></div>
|
||||
<div class="stat-desc"><%= @weather_art.wind_speed %> km/h</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Humidity</div>
|
||||
<div class="stat-value"><%= @weather_art.humidity %>%</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Visibility</div>
|
||||
<div class="stat-value"><%= @weather_art.visibility %> km</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Pressure</div>
|
||||
<div class="stat-value"><%= @weather_art.pressure %> hPa</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Cloud Cover</div>
|
||||
<div class="stat-value"><%= @weather_art.cloud %>%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Prompt -->
|
||||
<div class="mt-8 bg-base-200 p-6 rounded-box">
|
||||
<h3 class="font-display font-bold text-lg mb-3">AI Prompt</h3>
|
||||
<p class="text-base-content/70"><%= @weather_art.prompt %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Prompt -->
|
||||
<div class="bg-primary/10 backdrop-blur-md p-6 rounded-lg border border-primary/20">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<h3 class="font-display font-bold text-lg">AI Prompt</h3>
|
||||
</div>
|
||||
<p class="text-base-content/80 leading-relaxed">
|
||||
<%= @weather_art.prompt %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 上一个和下一个导航 -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mt-8">
|
||||
<% if @previous_weather_art %>
|
||||
<%= link_to city_weather_art_path(@city, @previous_weather_art),
|
||||
class: "btn btn-outline btn-primary w-full sm:w-auto flex items-center justify-center gap-2" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Previous Weather Art
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @next_weather_art %>
|
||||
<%= link_to city_weather_art_path(@city, @next_weather_art),
|
||||
class: "btn btn-outline btn-primary w-full sm:w-auto flex items-center justify-center gap-2" do %>
|
||||
Next Weather Art
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @previous_weather_art.nil? && @next_weather_art.nil? %>
|
||||
<div class="text-center text-base-content/70 py-4">
|
||||
No more Weather Arts available
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
53
app/workers/add_watermark_to_weather_art_worker.rb
Normal file
53
app/workers/add_watermark_to_weather_art_worker.rb
Normal file
@ -0,0 +1,53 @@
|
||||
class AddWatermarkToWeatherArtWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
def perform(weather_art_id)
|
||||
@weather_art = WeatherArt.find_by(id: weather_art_id)
|
||||
return unless @weather_art
|
||||
|
||||
add_watermark
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error adding watermark to WeatherArt #{weather_art_id}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :weather_art
|
||||
|
||||
def add_watermark
|
||||
return if weather_art.image_with_watermark.attached?
|
||||
|
||||
watermark_path = Rails.root.join("app/assets/images/today_ai_weather_copyright_watermark1.png")
|
||||
return unless File.exist?(watermark_path)
|
||||
|
||||
image_tempfile = nil
|
||||
watermark_tempfile = nil
|
||||
begin
|
||||
image_tempfile = weather_art.image.download
|
||||
return unless image_dimensions_are_sufficient?(image_tempfile.path)
|
||||
|
||||
image = ImageProcessing::Vips.source(image_tempfile.path)
|
||||
watermark = ImageProcessing::Vips.source(watermark_path)
|
||||
watermarked_image = image.composite(watermark, "overlay")
|
||||
watermark_tempfile = Tempfile.new([ "watermarked_image", ".png" ])
|
||||
watermarked_image.write_to_file(watermark_tempfile.path)
|
||||
weather_art.image_with_watermark.attach(
|
||||
io: File.open(watermark_tempfile.path),
|
||||
filename: "#{generate_filename("watermarked")}",
|
||||
content_type: "image/png"
|
||||
)
|
||||
ensure
|
||||
watermark_tempfile.unlink if watermark_tempfile
|
||||
end
|
||||
end
|
||||
|
||||
def image_dimensions_are_sufficient?(image_path)
|
||||
dimensions = ImageProcessing::Vips.source(image_path).sizes
|
||||
dimensions.width >= 200 && dimensions.height >= 200
|
||||
end
|
||||
|
||||
def generate_filename(prefix)
|
||||
"#{prefix}_#{weather_art.image.filename.base}"
|
||||
end
|
||||
end
|
@ -1,33 +1,32 @@
|
||||
class BatchGenerateWeatherArtsWorker
|
||||
include Sidekiq::Worker
|
||||
GENERATION_INTERVAL = 24.hours
|
||||
MAX_DURATION = 50.minutes
|
||||
SLEEP_DURATION = 120.seconds
|
||||
BATCH_SIZE = 20
|
||||
|
||||
def perform(*args)
|
||||
start_time = Time.current
|
||||
max_duration = 50.minutes
|
||||
|
||||
cities_to_process = get_eligible_cities
|
||||
|
||||
cities_to_process = get_eligible_cities.shuffle.take(BATCH_SIZE)
|
||||
cities_to_process.each do |city|
|
||||
break if Time.current - start_time > max_duration
|
||||
|
||||
GenerateWeatherArtJob.perform_now(city)
|
||||
sleep 1.minute # 确保不超过API限制
|
||||
break if Time.current - start_time > MAX_DURATION
|
||||
Rails.logger.info "Generating weather art for #{city.name}"
|
||||
GenerateWeatherArtWorker.perform_async(city.id)
|
||||
sleep SLEEP_DURATION
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_eligible_cities
|
||||
cutoff_time = Time.current - GENERATION_INTERVAL
|
||||
City.active
|
||||
.where(active: true)
|
||||
.where("last_weather_fetch IS NULL OR last_weather_fetch < ?", Date.today)
|
||||
# .select { |city| early_morning_in_timezone?(city.timezone) }
|
||||
.joins("LEFT JOIN (
|
||||
SELECT city_id, MAX(created_at) as last_generation_time
|
||||
FROM weather_arts
|
||||
GROUP BY city_id
|
||||
) latest_arts ON cities.id = latest_arts.city_id")
|
||||
.where("latest_arts.last_generation_time IS NULL OR latest_arts.last_generation_time < ?", cutoff_time)
|
||||
.order(:priority)
|
||||
end
|
||||
|
||||
# def early_morning_in_timezone?(timezone)
|
||||
# return false if timezone.blank?
|
||||
|
||||
# time = Time.current.in_time_zone(timezone)
|
||||
# time.hour == 2
|
||||
# end
|
||||
end
|
||||
|
32
app/workers/clean_ahoy_data_worker.rb
Normal file
32
app/workers/clean_ahoy_data_worker.rb
Normal file
@ -0,0 +1,32 @@
|
||||
# app/workers/clean_ahoy_data_worker.rb
|
||||
class CleanAhoyDataWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: :default, retry: false
|
||||
|
||||
def perform
|
||||
cleanup_old_events
|
||||
cleanup_old_visits
|
||||
log_cleanup_results
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cleanup_old_events
|
||||
cutoff_date = 3.months.ago
|
||||
deleted_events_count = Ahoy::Event.where("time < ?", cutoff_date).delete_all
|
||||
Rails.logger.info "Deleted #{deleted_events_count} old Ahoy events"
|
||||
end
|
||||
|
||||
def cleanup_old_visits
|
||||
cutoff_date = 3.months.ago
|
||||
deleted_visits_count = Ahoy::Visit.where("started_at < ?", cutoff_date).delete_all
|
||||
Rails.logger.info "Deleted #{deleted_visits_count} old Ahoy visits"
|
||||
end
|
||||
|
||||
def log_cleanup_results
|
||||
Rails.logger.info "Ahoy cleanup completed at #{Time.current}"
|
||||
Rails.logger.info "Remaining events: #{Ahoy::Event.count}"
|
||||
Rails.logger.info "Remaining visits: #{Ahoy::Visit.count}"
|
||||
end
|
||||
end
|
@ -1,43 +1,68 @@
|
||||
class GenerateWeatherArtWorker
|
||||
def perform(*args)
|
||||
city = args[0]
|
||||
return if city.last_weather_fetch&.today?
|
||||
include Sidekiq::Worker
|
||||
|
||||
weather_service = WeatherService.new
|
||||
ai_service = AiService.new
|
||||
def perform(city_id)
|
||||
@city = City.find(city_id)
|
||||
|
||||
# 获取天气数据
|
||||
weather_data = weather_service.get_weather(city.latitude, city.longitude)
|
||||
weather_data = fetch_weather_data
|
||||
return unless weather_data
|
||||
|
||||
# 生成提示词
|
||||
prompt = ai_service.generate_prompt(city, weather_data)
|
||||
prompt = generate_prompt(weather_data)
|
||||
return unless prompt
|
||||
|
||||
# 生成图像
|
||||
image_url = ai_service.generate_image(prompt)
|
||||
image_url = generate_image(prompt)
|
||||
return unless image_url
|
||||
|
||||
# 创建天气艺术记录
|
||||
weather_art = city.weather_arts.create!(
|
||||
weather_date: Date.today,
|
||||
**weather_data,
|
||||
prompt: prompt
|
||||
)
|
||||
create_weather_art(weather_data, prompt, image_url)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error generating weather art for city #{city_id}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
|
||||
# 下载并附加图像
|
||||
tempfile = Down.download(image_url)
|
||||
weather_art.image.attach(
|
||||
io: tempfile,
|
||||
filename: "#{city.country.name}-#{city.name.parameterize}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.png"
|
||||
)
|
||||
private
|
||||
|
||||
# 更新城市状态
|
||||
city.update!(
|
||||
last_weather_fetch: Time.current,
|
||||
last_image_generation: Time.current
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error "Error generating weather art for #{city.name}: #{e.message}"
|
||||
attr_reader :city
|
||||
|
||||
def fetch_weather_data
|
||||
WeatherService.new.get_weather(city.latitude, city.longitude)
|
||||
end
|
||||
|
||||
def generate_prompt(weather_data)
|
||||
AiService.new.generate_prompt(city, weather_data)
|
||||
end
|
||||
|
||||
def generate_image(prompt)
|
||||
AiService.new.generate_image(prompt)
|
||||
end
|
||||
|
||||
def create_weather_art(weather_data, prompt, image_url)
|
||||
tempfile = nil
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
weather_art = city.weather_arts.create!(
|
||||
weather_date: Date.today,
|
||||
prompt: prompt,
|
||||
**weather_data
|
||||
)
|
||||
|
||||
tempfile = Down.download(image_url)
|
||||
|
||||
weather_art.image.attach(
|
||||
io: File.open(tempfile.path),
|
||||
filename: generate_filename,
|
||||
content_type: "image/png"
|
||||
)
|
||||
|
||||
weather_art
|
||||
end
|
||||
ensure
|
||||
if tempfile
|
||||
tempfile.close
|
||||
tempfile.unlink
|
||||
end
|
||||
end
|
||||
|
||||
def generate_filename
|
||||
"#{city.country.name}-#{city.name.parameterize}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.png"
|
||||
end
|
||||
end
|
||||
|
58
app/workers/refresh_sitemap_worker.rb
Normal file
58
app/workers/refresh_sitemap_worker.rb
Normal file
@ -0,0 +1,58 @@
|
||||
class RefreshSitemapWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
def perform
|
||||
host = Rails.env.production? ? "https://todayaiweather.com" : "http://127.0.0.1:3000"
|
||||
Rails.application.routes.default_url_options[:host] = host
|
||||
SitemapGenerator::Sitemap.default_host = host
|
||||
if Rails.env.production?
|
||||
SitemapGenerator::Sitemap.adapter = SitemapGenerator::AwsSdkAdapter.new(
|
||||
ENV.fetch("AWS_BUCKET", Rails.application.credentials.dig(:aws, :bucket)),
|
||||
aws_access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws, :access_key_id)),
|
||||
aws_secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws, :secret_access_key)),
|
||||
aws_region: ENV.fetch("AWS_REGION", "wnam"),
|
||||
endpoint: ENV.fetch("AWS_ENDPOINT", Rails.application.credentials.dig(:aws, :endpoint)),
|
||||
)
|
||||
else
|
||||
SitemapGenerator::Sitemap.adapter = SitemapGenerator::AwsSdkAdapter.new(
|
||||
ENV.fetch("AWS_DEV_BUCKET", Rails.application.credentials.dig(:aws_dev, :bucket)),
|
||||
aws_access_key_id: ENV.fetch("AWS_DEV_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :access_key_id)),
|
||||
aws_secret_access_key: ENV.fetch("AWS_DEV_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :secret_access_key)),
|
||||
aws_region: ENV.fetch("AWS_DEV_REGION", "wnam"),
|
||||
endpoint: ENV.fetch("AWS_DEV_ENDPOINT", Rails.application.credentials.dig(:aws_dev, :endpoint)),
|
||||
)
|
||||
end
|
||||
SitemapGenerator::Sitemap.sitemaps_path = "sitemaps/"
|
||||
|
||||
SitemapGenerator::Sitemap.create do
|
||||
add root_path, changefreq: "daily", priority: 1.0
|
||||
add cities_path, changefreq: "daily", priority: 0.9
|
||||
add arts_path, changefreq: "daily", priority: 0.9
|
||||
|
||||
City.find_each do |city|
|
||||
add city_path(city),
|
||||
changefreq: "daily",
|
||||
priority: 0.8,
|
||||
lastmod: city.updated_at
|
||||
end
|
||||
|
||||
WeatherArt.includes(:city).find_each do |art|
|
||||
if art.image.attached?
|
||||
add city_weather_art_path(art.city, art),
|
||||
changefreq: "daily",
|
||||
priority: 0.7,
|
||||
lastmod: art.updated_at,
|
||||
images: [ {
|
||||
loc: url_for(art.image),
|
||||
title: "#{art.city.name} Weather Art - #{art.weather_date.strftime('%B %d, %Y')}"
|
||||
} ]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# SitemapGenerator::Sitemap.ping_search_engines if Rails.env.production?
|
||||
Rails.logger.info "Sitemap has been generated and uploaded to S3 successfully"
|
||||
rescue => e
|
||||
Rails.logger.error "Error refreshing sitemap: #{e.message}"
|
||||
end
|
||||
end
|
35
compose.yaml
35
compose.yaml
@ -1,15 +1,16 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
image: songtianlun/today_ai_weather:latest
|
||||
ports:
|
||||
- "2222:3000"
|
||||
#ports:
|
||||
# - "3000:3000"
|
||||
pull_policy: always
|
||||
environment:
|
||||
- RAILS_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:xxx@db:5432/db
|
||||
- RAILS_MASTER_KEY=xxx
|
||||
- DATABASE_URL=postgresql://postgres:${PG_PASSWORD}@db:5432/db
|
||||
- RAILS_MASTER_KEY=${RAILS_MASTER_KEY}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
networks:
|
||||
- dokploy-network
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
@ -19,9 +20,11 @@ services:
|
||||
command: bundle exec sidekiq
|
||||
environment:
|
||||
- RAILS_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:xxx@db:5432/db
|
||||
- RAILS_MASTER_KEY=xxx
|
||||
- DATABASE_URL=postgresql://postgres:${PG_PASSWORD}@db:5432/db
|
||||
- RAILS_MASTER_KEY=${RAILS_MASTER_KEY}
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
networks:
|
||||
- dokploy-network
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
@ -29,13 +32,21 @@ services:
|
||||
db:
|
||||
image: postgres:16
|
||||
volumes:
|
||||
- ./data/pg:/var/lib/postgresql/data
|
||||
- ../taw_data/pg:/var/lib/postgresql/data
|
||||
networks:
|
||||
- dokploy-network
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=xxx
|
||||
- POSTGRES_PASSWORD=${PG_PASSWORD}
|
||||
- POSTGRES_DB=db
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
command: redis-server --appendonly yes
|
||||
- ../taw_data/redis:/data
|
||||
networks:
|
||||
- dokploy-network
|
||||
command: redis-server --appendonly yes
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
@ -1 +1 @@
|
||||
0Upt112zhMfb3aADOgxSWUCTTUNgtTrpoivR9+KJFKcazTjqRauvQ9W+uicYrjJjv497gHMFhusWZysDl1/uwHHaoHvyp7auIjRHiOR1YKfRLgbMSYMgSOKlP4HbiZ66hjhT+4T50JjmwwLcPFtgPaZG3lasTAXejc9ejiSn+OsIGvJje5MXnPREId5pvC/SNW4uCYMe6OEsfhTlsj8hES7odHJzMsGQGGuVkcg1EimogOE9oWK6FS0bj45R+7HCiIYlbcmDkRNraGDMGJI+oaxx2mi+34ENoxqfbYEv4PhsWPYcOROK8FyrpXwvRG/a1J8LFxTLkVloKsBIInLojvd9u8zwpcTOtsaHOxsQV7gLzUt2GbUiT4EZUdzHpIDRboJEuVxm6ioBctcJ744xnkn5rxOoJH+JWRhxf+CVLYojcYMdkcyGEOHv0nXtpWtu/xsJT3fTqJBLuyGWNQUfTDsw3qQDUOrsgSNvXvhEu1iWptoByp4d0adaCDS0+TVt+BFg2xPzSnIU3IAXgSnhyLTVd87jxBMHtcngIj4HAj+nDIs65KT4dk4Y+ns3eWE345Z0CzUz1J6M8ikva0cQiBB6wxMl1yghysCcSfKX4z/KbuJv6StQJIqw4HkDZ9youXEbnmiXTxuEaC0Yg3SE0dFf5k9Rj5govLMdCf1Msj04RQHt0b08s3UdTgegriru+j7dC6yTaDbHkrNorREME3SENzdksI1D5k08D4Z+TYpO/+yyZE5qKELEse9TgJiFZ6g1H2sFs9zCxBDif67ovFSqrRaNgP7qBY8uEfFNVP6Y6eSnfccJlFBGlwflhwwA1osit+5rKlvhqQs37qsmFZRuj9BdrAJbdr/0ogEbLyeTEw3Zq+0m0/Vf4j5ueCv2eDpk/gSQdl869v+5anLTqMUWM4KV1wS1OrPkMC5XkZHLUo1/1+Dl6FKfZhWydRso1R9WL5B0Jk7UEWMPX0qoxxuixR1jwWzYmlehq0iNw7hQPdgwCbDtRxEMPr8MkUnG5Fd0YZUfnhHJ+EWVCZHKhUJclRSB9LnDKVPJr/oFuOHLmJg6Vll4WHCnIK9nCHdr7ed6O89vA5Z8DRlE/6yAXFlPKrCldWYbrL3BAgzQK8MxD5fzgzRkGiwVEtQwhbtbdDHUJ9rdAEmLJZj1UJXLIhX9jFspB9stHqyRxrcef/Y+h9dBvuFdvSIFEXA0TWHintx9YDgsJb591d6rjJn+JYEmZs9UONEEuiijPf9waqTb0oIrUQwnlUOX69bBipiU5ZQ2yQvepuRY3ooK3IGHvngxZPT0F8Mfh5Zcx9GGJd17LZb9DJOs7uOlrpXydToq/3Qm1q6TmgjbFYpC6iyrhDyE5XLL/XwVavh7m2QYqVbUXk2tGk2C0UR6CTE6tsV1+kDs1nylIEgAA5Nr43HQtRAXPmo0k8Txy5wMMIbLE1keqjl7XdGPnLsX9CJi6ue6KZxhigwcdakvW6Y=--H101ebDKrzdM39ll--a5R5klrhU6QgghGPjjWfAQ==
|
||||
zSnch4RlzPEpqZOE+TyBVBnHu+wyiNfNZoe1zi+cGNcgN/0BMw3xVQNIdu5ZgZzklukyimLlvkic7/G3PZE5aooqKIPeaGIgVKwdBvIGZYkTbrLTPtiA2Mx2iay4u6XI3eLDZZdy9g446GTngz9beQFR9s7dBbRBewOdIuCjA17seywsv722x97wHCv1xoGBeknK0DrEaYQcnECcy3G3esAsGeoKCQRMNHIf5/IbB6J+ZsoyOpnwtjOXdfEUnmtieLqvyw1xjgOxQTp2GMJk8zq+dGUBR3nIzplzccJbotppWfUbd9nxuT0stxWdFtKRPceynUfJ4awQNOmx+qGXi2/sYZH9lEB0nnyuKUpte6/bf1kcxcXyqTcsjaw0f9DXC3z9jx0pPbf2+O1MLZSFmo7Sr18mXjHVWjSCXnuYmWWkkuKESl4DwW5KScguWdnsf+/Y1eHds2MpE9dRLynlf5ONvFMY495iXuN4bR7UE5utAmU3imAg8vhErDqiro2L0GKMY5boMg+9NIObSlxwI6uAer8HaIKBVioiXGHFoFofR0LpmIKTbHiRF9ZZM3KCunsrQXAvRB4X7jL18JR08cDGGML2m03u9fcn9VuCYgcr+TLnsBozoG+ATP03kXc6lxkxjDvOc2z/AKqPmHUejCvsTLibFuEgw34E/NnVgcrC/Cggh/pp2Vmc6Yg2aQN7NcUmz0enGpzaTcnwiiB/8U8ig5WUovZP/iG3JH8S4KtBhDVz4hzz3FDOFBZCHd0rDrLtcyPtOcI3/WTZ0BgV5SXxA6L1gmM+aSYXtiyKZ2iUUOkMnNPvmHtimd+IWJZn2qXswX8RuE5QTqL5R5PRIdawdMSf9QrBSmDllNCo3czzClBRSNMYk2+ueWtiyvRuxP/xyNVl5e8nGOhAFWjCA+DVVA1xGWvS+R+3nxMSMtNaqTc2RPHQ3u4hY4k/0UH2TmnnzIJHS8vYScO4EzasTb/9FMeUEDnx369D7gvkXW6OapIKxwV0oQR8a/ZYUpOVyV6rSUIvEKrlpXCG8fh/6LwprYTPrnWe8iIwGqxKcuecrmtRSijAHgI2ZzMpSN91tomBBn9Qs+5svRbcXCO9KUKtjB9V82J7xqT+LwOyf1wa+v9zcO1aQxel2npRTsg7a4eIyIx1iyP7hHLAAuYIeEj1pBjiY/LiGZw+ScrXu1sIFTpt2SIKUOCVSN1y52/zXkwTRHc3qf4IdeHX6DqzWmWUiEHwm9Lva1fJ6poJ3vqWi7OLWJqyDtiN2FHJqybQ9mrgWPb0WBYi3umnfPy6ZYMQ6jQ8YarK8aFHsVdjUL0I4DKigAuer47p2rUcaFvySGCoMaSu5krSBw3VkDR6y1J8wIAm7nKZOSyoKwRjrgwXOl8yTN+zoemiaUMNJI1mTu1JQeg5rX2yUvwLEu5hu/5eOuRCkoSiwcG1+LmcnZbYRgSRvDzRcKYKZ/5hzQkPCLRkwAhh1Zp7mtc=--dyeF3fjnBpbK0tnw--nWdBu1crWT0VX9aHfnoKRw==
|
@ -50,7 +50,8 @@ Rails.application.configure do
|
||||
config.cache_store = :solid_cache_store
|
||||
|
||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
# config.active_job.queue_adapter = :solid_queue
|
||||
config.active_job.queue_adapter = :sidekiq
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
|
14
config/initializers/ahoy.rb
Normal file
14
config/initializers/ahoy.rb
Normal file
@ -0,0 +1,14 @@
|
||||
class Ahoy::Store < Ahoy::DatabaseStore
|
||||
end
|
||||
|
||||
# set to true for JavaScript tracking
|
||||
Ahoy.api = true
|
||||
|
||||
# set to true for geocoding (and add the geocoder gem to your Gemfile)
|
||||
# we recommend configuring local geocoding as well
|
||||
# see https://github.com/ankane/ahoy#geocoding
|
||||
Ahoy.geocode = false
|
||||
|
||||
Ahoy.visit_duration = 30.minutes
|
||||
Ahoy.server_side_visits = :when_needed
|
||||
RETENTION_PERIOD = 3.months
|
19
config/initializers/aws.rb
Normal file
19
config/initializers/aws.rb
Normal file
@ -0,0 +1,19 @@
|
||||
if Rails.env.production?
|
||||
Aws.config.update({
|
||||
region: ENV.fetch("AWS_REGION", "wnam"),
|
||||
credentials: Aws::Credentials.new(
|
||||
ENV.fetch("AWS_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws, :access_key_id)),
|
||||
ENV.fetch("AWS_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws, :secret_access_key))
|
||||
),
|
||||
endpoint: ENV.fetch("AWS_ENDPOINT", Rails.application.credentials.dig(:aws, :endpoint))
|
||||
})
|
||||
else
|
||||
Aws.config.update({
|
||||
region: ENV.fetch("AWS_DEV_REGION", "wnam"),
|
||||
credentials: Aws::Credentials.new(
|
||||
ENV.fetch("AWS_DEV_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :access_key_id)),
|
||||
ENV.fetch("AWS_DEV_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :secret_access_key))
|
||||
),
|
||||
endpoint: ENV.fetch("AWS_DEV_ENDPOINT", Rails.application.credentials.dig(:aws_dev, :endpoint))
|
||||
})
|
||||
end
|
14
config/initializers/kaminari_config.rb
Normal file
14
config/initializers/kaminari_config.rb
Normal file
@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Kaminari.configure do |config|
|
||||
# config.default_per_page = 25
|
||||
# config.max_per_page = nil
|
||||
# config.window = 4
|
||||
# config.outer_window = 0
|
||||
# config.left = 0
|
||||
# config.right = 0
|
||||
# config.page_method_name = :page
|
||||
# config.param_name = :page
|
||||
# config.max_pages = nil
|
||||
# config.params_on_first_page = false
|
||||
end
|
52
config/initializers/meta_tags.rb
Normal file
52
config/initializers/meta_tags.rb
Normal file
@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Use this setup block to configure all options available in MetaTags.
|
||||
MetaTags.configure do |config|
|
||||
config.title_limit = 70
|
||||
config.description_limit = 160
|
||||
config.keywords_limit = 255
|
||||
# How many characters should the title meta tag have at most. Default is 70.
|
||||
# Set to nil or 0 to remove limits.
|
||||
# config.title_limit = 70
|
||||
|
||||
# When true, site title will be truncated instead of title. Default is false.
|
||||
# config.truncate_site_title_first = false
|
||||
|
||||
# Add HTML attributes to the <title> HTML tag. Default is {}.
|
||||
# config.title_tag_attributes = {}
|
||||
|
||||
# Natural separator when truncating. Default is " " (space character).
|
||||
# Set to nil to disable natural separator.
|
||||
# This also allows you to use a whitespace regular expression (/\s/) or
|
||||
# a Unicode space (/\p{Space}/).
|
||||
# config.truncate_on_natural_separator = " "
|
||||
|
||||
# Maximum length of the page description. Default is 300.
|
||||
# Set to nil or 0 to remove limits.
|
||||
# config.description_limit = 300
|
||||
|
||||
# Maximum length of the keywords meta tag. Default is 255.
|
||||
# config.keywords_limit = 255
|
||||
|
||||
# Default separator for keywords meta tag (used when an Array passed with
|
||||
# the list of keywords). Default is ", ".
|
||||
# config.keywords_separator = ', '
|
||||
|
||||
# When true, keywords will be converted to lowercase, otherwise they will
|
||||
# appear on the page as is. Default is true.
|
||||
# config.keywords_lowercase = true
|
||||
|
||||
# When true, the output will not include new line characters between meta tags.
|
||||
# Default is false.
|
||||
# config.minify_output = false
|
||||
|
||||
# When false, generated meta tags will be self-closing (<meta ... />) instead
|
||||
# of open (`<meta ...>`). Default is true.
|
||||
# config.open_meta_tags = true
|
||||
|
||||
# List of additional meta tags that should use "property" attribute instead
|
||||
# of "name" attribute in <meta> tags.
|
||||
# config.property_tags.push(
|
||||
# 'x-hearthstone:deck',
|
||||
# )
|
||||
end
|
9
config/initializers/schedule_tasks.rb
Normal file
9
config/initializers/schedule_tasks.rb
Normal file
@ -0,0 +1,9 @@
|
||||
# config/initializers/schedule_tasks.rb
|
||||
Rails.application.config.after_initialize do
|
||||
begin
|
||||
RefreshSitemapWorker.perform_async
|
||||
Rails.logger.info "Startup task (RefreshSitemapWorker) scheduled successfully"
|
||||
rescue => e
|
||||
Rails.logger.error "Error scheduling startup task: #{e.message}"
|
||||
end
|
||||
end
|
@ -5,8 +5,13 @@ Sidekiq.configure_server do |config|
|
||||
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1") }
|
||||
config.logger.level = Logger::INFO
|
||||
config.on(:startup) do
|
||||
Sidekiq.schedule = YAML.load_file(File.expand_path("../../sidekiq.yml", __FILE__))
|
||||
Sidekiq::Scheduler.reload_schedule!
|
||||
schedule_file = "config/sidekiq_scheduler.yml"
|
||||
if File.exist?(schedule_file)
|
||||
Sidekiq::Scheduler.enabled = true
|
||||
Sidekiq::Scheduler.dynamic = true
|
||||
Sidekiq.schedule = YAML.load_file(schedule_file)
|
||||
Sidekiq::Scheduler.reload_schedule!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -6,6 +6,7 @@ Rails.application.routes.draw do
|
||||
resources :cities, only: [ :index, :show ] do
|
||||
resources :weather_arts, path: "weather", only: [ :show ], param: :slug
|
||||
end
|
||||
resources :arts, only: [ :index ]
|
||||
|
||||
# namespace :admin do
|
||||
# resources :cities
|
||||
@ -17,12 +18,14 @@ Rails.application.routes.draw do
|
||||
get "cities/index"
|
||||
get "cities/show"
|
||||
get "home/index"
|
||||
get "sitemaps/*path", to: "sitemaps#show", format: false
|
||||
|
||||
devise_for :admin_users, ActiveAdmin::Devise.config
|
||||
ActiveAdmin.routes(self)
|
||||
|
||||
# mount Sidekiq::Web => '/sidekiq'
|
||||
authenticate :admin_user do
|
||||
mount Sidekiq::Web => "/sidekiq"
|
||||
mount Sidekiq::Web => "/admin/tasks"
|
||||
end
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
|
||||
|
@ -1,10 +0,0 @@
|
||||
:schedule:
|
||||
sample_job:
|
||||
cron: '0 * * * *' # 每小时执行
|
||||
class: BatchGenerateWeatherArtsWorker
|
||||
|
||||
:concurrency: 5
|
||||
:queues:
|
||||
- default
|
||||
- mailers
|
||||
- low
|
18
config/sidekiq_scheduler.yml
Normal file
18
config/sidekiq_scheduler.yml
Normal file
@ -0,0 +1,18 @@
|
||||
batch_generate_weather:
|
||||
cron: '0 */1 * * *'
|
||||
class: BatchGenerateWeatherArtsWorker
|
||||
description: "Generate weather arts every 2 hours"
|
||||
enabled: true
|
||||
|
||||
refresh_sitemap:
|
||||
cron: '0 5 * * *'
|
||||
class: RefreshSitemapWorker
|
||||
queue: default
|
||||
description: "Refresh sitemap daily"
|
||||
enabled: true
|
||||
|
||||
clean_ahoy_data:
|
||||
cron: '0 0 * * 0'
|
||||
class: CleanAhoyDataWorker
|
||||
queue: default
|
||||
enabled: true
|
58
config/sitemap.rb
Normal file
58
config/sitemap.rb
Normal file
@ -0,0 +1,58 @@
|
||||
# Set the host name for URL creation
|
||||
host = Rails.env.production? ? "https://todayaiweather.com" : "http://127.0.0.1:3000"
|
||||
Rails.application.routes.default_url_options[:host] = host
|
||||
SitemapGenerator::Sitemap.adapter = SitemapGenerator::AwsSdkAdapter.new(
|
||||
Rails.application.credentials.dig(:aws, :bucket),
|
||||
aws_access_key_id: Rails.application.credentials.dig(:aws, :access_key_id),
|
||||
aws_secret_access_key: Rails.application.credentials.dig(:aws, :secret_access_key),
|
||||
aws_region: Rails.application.credentials.dig(:aws, :region)
|
||||
)
|
||||
SitemapGenerator::Sitemap.sitemaps_path = "sitemaps/"
|
||||
|
||||
SitemapGenerator::Sitemap.default_host = host
|
||||
|
||||
SitemapGenerator::Sitemap.create do
|
||||
add root_path, changefreq: "daily", priority: 1.0
|
||||
add cities_path, changefreq: "daily", priority: 0.9
|
||||
add arts_path, changefreq: "daily", priority: 0.9
|
||||
|
||||
City.find_each do |city|
|
||||
add city_path(city),
|
||||
changefreq: "daily",
|
||||
priority: 0.8,
|
||||
lastmod: city.updated_at
|
||||
end
|
||||
|
||||
WeatherArt.includes(:city).find_each do |art|
|
||||
add city_weather_art_path(art.city, art),
|
||||
changefreq: "daily",
|
||||
priority: 0.7,
|
||||
lastmod: art.updated_at,
|
||||
images: [ {
|
||||
loc: url_for(art.image),
|
||||
title: "#{art.city.name} Weather Art - #{art.weather_date.strftime('%B %d, %Y')}"
|
||||
} ] if art.image.attached?
|
||||
end
|
||||
# Put links creation logic here.
|
||||
#
|
||||
# The root path '/' and sitemap index file are added automatically for you.
|
||||
# Links are added to the Sitemap in the order they are specified.
|
||||
#
|
||||
# Usage: add(path, options={})
|
||||
# (default options are used if you don't specify)
|
||||
#
|
||||
# Defaults: :priority => 0.5, :changefreq => 'weekly',
|
||||
# :lastmod => Time.now, :host => default_host
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# Add '/articles'
|
||||
#
|
||||
# add articles_path, :priority => 0.7, :changefreq => 'daily'
|
||||
#
|
||||
# Add all articles:
|
||||
#
|
||||
# Article.find_each do |article|
|
||||
# add article_path(article), :lastmod => article.updated_at
|
||||
# end
|
||||
end
|
@ -19,11 +19,11 @@ build:
|
||||
# bucket: your_own_bucket-<%= Rails.env %>
|
||||
amazon_dev:
|
||||
service: S3
|
||||
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :access_key_id)) %>
|
||||
secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :secret_access_key)) %>
|
||||
region: <%= ENV.fetch("AWS_REGION", "wnam") %>
|
||||
bucket: <%= ENV.fetch("AWS_BUCKET", Rails.application.credentials.dig(:aws_dev, :bucket)) %>
|
||||
endpoint: <%= ENV.fetch("AWS_ENDPOINT", Rails.application.credentials.dig(:aws_dev, :endpoint)) %>
|
||||
access_key_id: <%= ENV.fetch("AWS_DEV_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :access_key_id)) %>
|
||||
secret_access_key: <%= ENV.fetch("AWS_DEV_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :secret_access_key)) %>
|
||||
region: <%= ENV.fetch("AWS_DEV_REGION", "wnam") %>
|
||||
bucket: <%= ENV.fetch("AWS_DEV_BUCKET", Rails.application.credentials.dig(:aws_dev, :bucket)) %>
|
||||
endpoint: <%= ENV.fetch("AWS_DEV_ENDPOINT", Rails.application.credentials.dig(:aws_dev, :endpoint)) %>
|
||||
|
||||
amazon:
|
||||
service: S3
|
||||
|
@ -0,0 +1,8 @@
|
||||
class RemoveLastFetchFieldsFromCities < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
remove_column :cities, :last_weather_fetch
|
||||
remove_column :cities, :last_image_generation
|
||||
|
||||
add_index :weather_arts, [ :city_id, :weather_date ]
|
||||
end
|
||||
end
|
61
db/migrate/20250126155239_create_ahoy_visits_and_events.rb
Normal file
61
db/migrate/20250126155239_create_ahoy_visits_and_events.rb
Normal file
@ -0,0 +1,61 @@
|
||||
class CreateAhoyVisitsAndEvents < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :ahoy_visits do |t|
|
||||
t.string :visit_token
|
||||
t.string :visitor_token
|
||||
|
||||
# the rest are recommended but optional
|
||||
# simply remove any you don't want
|
||||
|
||||
# user
|
||||
t.references :user
|
||||
|
||||
# standard
|
||||
t.string :ip
|
||||
t.text :user_agent
|
||||
t.text :referrer
|
||||
t.string :referring_domain
|
||||
t.text :landing_page
|
||||
|
||||
# technology
|
||||
t.string :browser
|
||||
t.string :os
|
||||
t.string :device_type
|
||||
|
||||
# location
|
||||
t.string :country
|
||||
t.string :region
|
||||
t.string :city
|
||||
t.float :latitude
|
||||
t.float :longitude
|
||||
|
||||
# utm parameters
|
||||
t.string :utm_source
|
||||
t.string :utm_medium
|
||||
t.string :utm_term
|
||||
t.string :utm_content
|
||||
t.string :utm_campaign
|
||||
|
||||
# native apps
|
||||
t.string :app_version
|
||||
t.string :os_version
|
||||
t.string :platform
|
||||
|
||||
t.datetime :started_at
|
||||
end
|
||||
|
||||
add_index :ahoy_visits, :visit_token, unique: true
|
||||
add_index :ahoy_visits, [ :visitor_token, :started_at ]
|
||||
|
||||
create_table :ahoy_events do |t|
|
||||
t.references :visit
|
||||
t.references :user
|
||||
|
||||
t.string :name
|
||||
t.text :properties
|
||||
t.datetime :time
|
||||
end
|
||||
|
||||
add_index :ahoy_events, [ :name, :time ]
|
||||
end
|
||||
end
|
47
db/schema.rb
generated
47
db/schema.rb
generated
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_01_22_053220) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_01_26_155239) do
|
||||
create_table "active_admin_comments", force: :cascade do |t|
|
||||
t.string "namespace"
|
||||
t.text "body"
|
||||
@ -65,6 +65,48 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_22_053220) do
|
||||
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
|
||||
end
|
||||
|
||||
create_table "ahoy_events", force: :cascade do |t|
|
||||
t.integer "visit_id"
|
||||
t.integer "user_id"
|
||||
t.string "name"
|
||||
t.text "properties"
|
||||
t.datetime "time"
|
||||
t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time"
|
||||
t.index ["user_id"], name: "index_ahoy_events_on_user_id"
|
||||
t.index ["visit_id"], name: "index_ahoy_events_on_visit_id"
|
||||
end
|
||||
|
||||
create_table "ahoy_visits", force: :cascade do |t|
|
||||
t.string "visit_token"
|
||||
t.string "visitor_token"
|
||||
t.integer "user_id"
|
||||
t.string "ip"
|
||||
t.text "user_agent"
|
||||
t.text "referrer"
|
||||
t.string "referring_domain"
|
||||
t.text "landing_page"
|
||||
t.string "browser"
|
||||
t.string "os"
|
||||
t.string "device_type"
|
||||
t.string "country"
|
||||
t.string "region"
|
||||
t.string "city"
|
||||
t.float "latitude"
|
||||
t.float "longitude"
|
||||
t.string "utm_source"
|
||||
t.string "utm_medium"
|
||||
t.string "utm_term"
|
||||
t.string "utm_content"
|
||||
t.string "utm_campaign"
|
||||
t.string "app_version"
|
||||
t.string "os_version"
|
||||
t.string "platform"
|
||||
t.datetime "started_at"
|
||||
t.index ["user_id"], name: "index_ahoy_visits_on_user_id"
|
||||
t.index ["visit_token"], name: "index_ahoy_visits_on_visit_token", unique: true
|
||||
t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at"
|
||||
end
|
||||
|
||||
create_table "cities", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.float "latitude"
|
||||
@ -72,8 +114,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_22_053220) do
|
||||
t.boolean "active"
|
||||
t.integer "priority"
|
||||
t.string "timezone"
|
||||
t.datetime "last_weather_fetch"
|
||||
t.datetime "last_image_generation"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "slug"
|
||||
@ -132,6 +172,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_22_053220) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "slug"
|
||||
t.index ["city_id", "weather_date"], name: "index_weather_arts_on_city_id_and_weather_date"
|
||||
t.index ["city_id"], name: "index_weather_arts_on_city_id"
|
||||
t.index ["slug"], name: "index_weather_arts_on_slug", unique: true
|
||||
end
|
||||
|
24
db/seeds.rb
24
db/seeds.rb
@ -7,7 +7,8 @@
|
||||
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
|
||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||
# end
|
||||
AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password') if Rails.env.development?
|
||||
# AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password') if Rails.env.development?
|
||||
AdminUser.create!(email: 'admin@example.com', password: 'password', password_confirmation: 'password')
|
||||
|
||||
WeatherArt.delete_all
|
||||
City.delete_all
|
||||
@ -182,3 +183,24 @@ guangzhou_weather_art2.image.attach(
|
||||
filename: "sample-guangzhou-weather-art-2.png",
|
||||
content_type: "image/png"
|
||||
)
|
||||
|
||||
guangzhou_weather_art3 = WeatherArt.create!(
|
||||
city: guangzhou,
|
||||
weather_date: 2.day.ago,
|
||||
description: 'Sunny with some clouds',
|
||||
temperature: 23.21,
|
||||
feeling_temp: 22.11,
|
||||
humidity: 65,
|
||||
wind_scale: "0.7",
|
||||
wind_speed: 15,
|
||||
precipitation: 0,
|
||||
pressure: 1014,
|
||||
visibility: 10000,
|
||||
cloud: 30,
|
||||
prompt: "A clear day in Guangzhou, China. In the foreground, people are walking near the iconic Canton Tower, surrounded by vibrant greenery and modern buildings. The sky is blue with no clouds, and the sun is shining brightly. It's a perfect day, with a temperature of around 23 degrees Celsius. The scene captures the lively atmosphere of the city, showing locals enjoying their day out."
|
||||
)
|
||||
guangzhou_weather_art3.image.attach(
|
||||
io: File.open("db/seeds/images/sample-guangzhou-weather-art-2.png"),
|
||||
filename: "sample-guangzhou-weather-art-2.png",
|
||||
content_type: "image/png"
|
||||
)
|
||||
|
@ -8,9 +8,7 @@ City.create!([
|
||||
country: australia,
|
||||
timezone: 'Australia/Sydney',
|
||||
active: true,
|
||||
priority: 80,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 80
|
||||
},
|
||||
{
|
||||
name: 'Melbourne',
|
||||
@ -19,8 +17,6 @@ City.create!([
|
||||
country: australia,
|
||||
timezone: 'Australia/Melbourne',
|
||||
active: true,
|
||||
priority: 75,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 75
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: bangladesh,
|
||||
timezone: 'Asia/Dhaka',
|
||||
active: true,
|
||||
priority: 85,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 85
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: brazil,
|
||||
timezone: 'America/Sao_Paulo',
|
||||
active: true,
|
||||
priority: 80,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 80
|
||||
}
|
||||
])
|
||||
|
@ -6,7 +6,5 @@ City.create!(
|
||||
priority: 50,
|
||||
country: canada,
|
||||
timezone: 'America/Toronto',
|
||||
active: true,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
active: true
|
||||
)
|
||||
|
@ -8,9 +8,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Beijing',
|
||||
@ -19,9 +17,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Shenzhen',
|
||||
@ -30,9 +26,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Guangzhou',
|
||||
@ -41,9 +35,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Chengdu',
|
||||
@ -52,9 +44,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Tianjin',
|
||||
@ -63,9 +53,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Wuhan',
|
||||
@ -74,9 +62,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Dongguan',
|
||||
@ -85,9 +71,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Chongqing',
|
||||
@ -96,9 +80,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: "Xi'an",
|
||||
@ -107,9 +89,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Hangzhou',
|
||||
@ -118,9 +98,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Foshan',
|
||||
@ -129,9 +107,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Nanjing',
|
||||
@ -140,9 +116,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Hong Kong',
|
||||
@ -151,9 +125,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Hong_Kong',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Shenyang',
|
||||
@ -162,9 +134,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Zhengzhou',
|
||||
@ -173,9 +143,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Qingdao',
|
||||
@ -184,9 +152,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Suzhou',
|
||||
@ -195,9 +161,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Changsha',
|
||||
@ -206,9 +170,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Jinan',
|
||||
@ -217,9 +179,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Kunming',
|
||||
@ -228,9 +188,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Harbin',
|
||||
@ -239,9 +197,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Shijiazhuang',
|
||||
@ -250,9 +206,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Hefei',
|
||||
@ -261,9 +215,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Dalian',
|
||||
@ -272,9 +224,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Xiamen',
|
||||
@ -283,9 +233,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Nanning',
|
||||
@ -294,9 +242,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Changchun',
|
||||
@ -305,9 +251,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Taiyuan',
|
||||
@ -316,9 +260,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'New Taipei City',
|
||||
@ -327,9 +269,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Taipei',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Guiyang',
|
||||
@ -338,9 +278,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Wuxi',
|
||||
@ -349,9 +287,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Shantou',
|
||||
@ -360,9 +296,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Ürümqi',
|
||||
@ -371,9 +305,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Zhongshan',
|
||||
@ -382,9 +314,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Ningbo',
|
||||
@ -393,9 +323,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Fuzhou',
|
||||
@ -404,9 +332,7 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Nanchang',
|
||||
@ -415,8 +341,6 @@ City.create!([
|
||||
country: china,
|
||||
timezone: 'Asia/Shanghai',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: egypt,
|
||||
timezone: 'Africa/Cairo',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: france,
|
||||
timezone: 'Europe/Paris',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,9 +8,7 @@ City.create!([
|
||||
country: germany,
|
||||
timezone: 'Europe/Berlin',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Berlin',
|
||||
@ -19,8 +17,6 @@ City.create!([
|
||||
country: germany,
|
||||
timezone: 'Europe/Berlin',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,9 +8,7 @@ City.create!([
|
||||
country: india,
|
||||
timezone: 'Asia/Kolkata',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Bengaluru',
|
||||
@ -19,8 +17,6 @@ City.create!([
|
||||
country: india,
|
||||
timezone: 'Asia/Kolkata',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,9 +8,7 @@ City.create!([
|
||||
country: japan,
|
||||
timezone: 'Asia/Tokyo',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Yokohama',
|
||||
@ -19,8 +17,6 @@ City.create!([
|
||||
country: japan,
|
||||
timezone: 'Asia/Tokyo',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: mexico,
|
||||
timezone: 'America/Mexico_City',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: nigeria,
|
||||
timezone: 'Africa/Lagos',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: pakistan,
|
||||
timezone: 'Asia/Karachi',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,9 +8,7 @@ City.create!([
|
||||
country: russia,
|
||||
timezone: 'Europe/Moscow',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
},
|
||||
{
|
||||
name: 'Sankt Petersburg',
|
||||
@ -19,8 +17,6 @@ City.create!([
|
||||
country: russia,
|
||||
timezone: 'Europe/Moscow',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: saudi_arabia,
|
||||
timezone: 'Asia/Riyadh',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: singapore,
|
||||
timezone: 'Asia/Singapore',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
@ -8,8 +8,6 @@ City.create!([
|
||||
country: south_korea,
|
||||
timezone: 'Asia/Seoul',
|
||||
active: true,
|
||||
priority: 100,
|
||||
last_weather_fetch: 10.hours.ago,
|
||||
last_image_generation: 10.hours.ago
|
||||
priority: 100
|
||||
}
|
||||
])
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user