Compare commits

...

52 Commits

Author SHA1 Message Date
dependabot[bot]
fdbde69445
chore(deps): bump aws-sdk-s3 from 1.177.0 to 1.178.0
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.177.0 to 1.178.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-26 16:55:48 +00:00
f918a42619 fix: correct string quotation in city scope
- Change single quotes to double quotes for adapter name check

This change ensures consistent usage of double quotes in the
conditional check for the SQLite adapter. It does not affect
any functionality but improves code readability.
2025-01-27 00:50:35 +08:00
adb671e668 feat: add least popular active cities panel
- Introduce new panel in the admin dashboard for displaying
  the least popular active cities.
- Implement a database scope `least_popular_active` to
  retrieve cities based on their view counts.
- Ensure compatibility with both SQLite and PostgreSQL
  for fetching city view data.

This addition enhances the admin dashboard by allowing
administrators to easily identify and manage cities that
are receiving less user interaction, thus aiding in strategic
planning for engagement and promotion.
2025-01-27 00:48:07 +08:00
50321533f7 chore: clean up trailing whitespace and formatting
- Removed unnecessary leading and trailing blank lines in several
  ActiveAdmin register files.
- Reformatted string delimiters for consistency, changing single quotes
to double quotes in tracking events in the Cities and WeatherArts
controllers.
- Ensured proper spacing in array definitions across several
  models, including Ahoy::Event and Ahoy::Visit.

These changes improve code readability and maintain consistency
throughout the codebase by ensuring uniform use of quotes and
removing excess whitespace.
2025-01-27 00:43:36 +08:00
dd6cd0451d feat: add ahoy analytics for event tracking
- Integrate Ahoy gem for tracking user events and visits
- Create models for Ahoy events and visits
- Implement admin interfaces for managing events and visits
- Add background job for cleaning up old analytics data
- Update application controller and other relevant controllers to track specific actions

This commit implements a comprehensive event tracking system that logs user interactions
within the application. Additionally, it includes mechanisms for managing and
cleaning historical visit and event data, ensuring efficient data handling.
2025-01-27 00:43:18 +08:00
5f30e08a6e refactor: update city ordering
Order cities by name instead of filtering active cities. This change simplifies the CitiesController and improves code readability.

- Update City.includes method in CitiesController
- Remove active filter in City.includes method
2025-01-26 23:49:35 +08:00
bf10e41c1e fix: update view variable names for clarity
- Rename page view variables for consistency:
  - `busuanzi_value_page_pv` to `busuanzi_page_pv`
  - `busuanzi_value_page_uv` to `busuanzi_page_uv`
  - `busuanzi_value_site_pv` to `busuanzi_site_pv`
  - `busuanzi_value_site_uv` to `busuanzi_site_uv`

These changes improve the readability of the code by removing redundant parts of the variable names. This ensures that variables accurately reflect their purpose without unnecessary prefixes. The functionality remains unchanged.
2025-01-26 12:35:55 +08:00
9d1ff31c53 style: make busuanzi_container visible
- Change visibility of busuanzi_container from hidden to default

This change allows page view counts to be visible to users, improving
transparency regarding page engagement metrics.
2025-01-26 02:36:26 +08:00
155669866a style: adjust font size in footer container
Some checks failed
CI / scan_ruby (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
Docker / docker (push) Has been cancelled
- Change font size from 'text-sm' to 'text-xs' for better visibility

This adjustment improves the aesthetics of the footer by ensuring that the
text is appropriately sized and matches the overall design of the page.
2025-01-26 02:29:43 +08:00
8cacf2a9ff style: update footer layout and visibility
- Adjust footer padding for improved spacing
- Change visitor and view statistics display to a more concise
  format
- Maintain consistent font sizes for better readability

These changes enhance the visual presentation of the footer
while maintaining the functionality of hidden visitor statistics.
2025-01-26 02:27:19 +08:00
b0bdb72f8e feat: enhance site statistics display
- Update footer padding for better spacing
- Add hidden container for site statistics
- Modify display of page and total site statistics

These changes improve the presentation and organization of site statistics, making it easier for users to understand site traffic and engagement.
2025-01-26 02:19:56 +08:00
cea07ccfea chore: remove active_admin.js and add analytics scripts
- Removed the active_admin.js file from the project as it is no longer needed.
- Added the Busuanzi site statistics script to the application layout for
  tracking page views and visitors.
- Updated the footer layout to include site statistics, providing users
  with real-time view and visitor counts.

These changes streamline the asset management by removing unused files and
integrate site analytics directly into the footer for better visibility.
2025-01-26 02:08:48 +08:00
5c8308a991 feat: enhance city weather art view
- Update layout with improved styles and spacing
- Modify sections for better visual hierarchy
- Add gradient effects and responsive design adjustments

These changes enhance the overall user experience by providing a
more visually appealing and organized layout for the city
weather art view. Adjustments include background effects,
updated navigation, and improved display of weather statistics
and history records.
2025-01-26 00:35:08 +08:00
5feaee4922 feat: add sitemap functionality
- Create SitemapsController to serve sitemaps
- Configure AWS S3 storage for sitemaps
- Update routes to include sitemap paths
- Add SitemapsHelper module
- Configure SitemapGenerator with AWS adapter
- Update storage configurations for AWS

This feature adds sitemap functionality to the application, enabling search engines to discover and index its content more efficiently. It includes configuration for AWS S3 storage to host the sitemaps and updates the application's routes to serve them.
2025-01-26 00:07:44 +08:00
ead795266e refactor: simplify sitemap scheduling process
- Removed conditional check for production environment in
  the initializer to ensure the sitemap refresh worker
  always runs at startup.
- Renamed Sidekiq configuration file from sidekiq.yml to
  sidekiq_scheduler.yml to better reflect its purpose.

These changes streamline the initialization process
for scheduled tasks and enhance the clarity of the
configuration files used in the project.
2025-01-25 11:24:44 +08:00
1ca468f9af fix: improve error handling for sitemap task
- Add error handling when scheduling the RefreshSitemapWorker in
  production.
- Log successful scheduling and errors for improved diagnostics.

This change ensures that any issues with scheduling the worker are
logged and can be addressed promptly, enhancing the reliability of
the application in production.
2025-01-25 11:05:53 +08:00
1f35664590 fix: correct conditional for sitemap refresh
- Update the condition to check for 'RAILS_BUILD' environment variable
- Allow RefreshSitemapWorker to run in development/test environments

This change clarifies the logic for executing the sitemap refresh worker by
inverting the condition to allow it to run when 'RAILS_BUILD' is not set,
ensuring proper functionality during development and testing phases.
2025-01-25 11:01:29 +08:00
40631fe95b fix: alter sitemap refresh condition
- Change the condition to prevent sitemap refresh in production when
  `RAILS_BUILD` is set.
- Ensures that the task only runs in development or non-production
  environments, avoiding potential conflicts or wasted resources.

This change helps to streamline the initialization process and enhances
performance by not triggering unnecessary background tasks.
2025-01-25 10:56:13 +08:00
f7c5ae4ee7 chore: conditionally refresh sitemap
- Modified the schedule_tasks.rb initializer to only run RefreshSitemapWorker.perform_async unless RAILS_BUILD environment variable is set
This change optimizes the build process by preventing unnecessary sitemap refreshes during build time, thus improving overall project performance.
2025-01-25 10:54:19 +08:00
742c94ced1 style: update layout of city show view
- Change stats div to be responsive
- Adjust coordinates display format for better readability

This update enhances the layout of the city show view by making
it more responsive and improving the display of weather and
geographical data. Additionally, the startup task for
refreshing the sitemap is now consistently executed,
removing unnecessary complexity.
2025-01-25 10:51:14 +08:00
c37a93bcdf feat: remove trailing newline at end of file
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
Docker / docker (push) Waiting to run
Remove trailing newline at end of file in `schedule_tasks.rb`.

- Modify file to remove unnecessary newline at end of file.
2025-01-25 01:55:00 +08:00
7ebf9cefae feat: switch startup task to Sidekiq with Redis cache
- Update schedule tasks to use Sidekiq and Redis
- Improve task scheduling with caching
- Remove unnecessary code and error handling

This change enhances the reliability and performance of the startup task, leveraging the power of Sidekiq and Redis for background job processing and caching. The improvements ensure a smoother and more efficient application startup experience.
2025-01-25 01:49:28 +08:00
dd37e2835b style: add newline at end of schedule_tasks.rb
This commit adds a newline at the end of the 'schedule_tasks.rb' file to comply with the standard coding practices. Proper file formatting helps in maintaining code consistency across the project.
2025-01-25 01:38:35 +08:00
4e1fb58abf feat: prevent startup task from running in development environment
- Updated schedule_tasks.rb to include RAILS_BUILD check
- Improved application startup behavior in development mode

This change ensures the startup task does not run unnecessarily in development environment, reducing application startup time and resources usage.
2025-01-25 01:38:12 +08:00
5bc06007b2 refactor: improve countries find_resource method
- Update `find_resource` method to use `scoped_collection.friendly.find(params[:id])`

This refactoring improves the find_resource method to work with friendly URLs, making it more robust and user-friendly.
2025-01-25 01:29:50 +08:00
84c224cf8d feat: add startup scheduling for production
- Schedule RefreshSitemapWorker after initialization
- Implement error handling for scheduling task
- Use Rails cache to prevent multiple tasks running simultaneously

This commit introduces a mechanism that schedules the
RefreshSitemapWorker to run once after the application starts
in production. It ensures that the task does not run
multiple times concurrently by using a cache key.
Error handling is included to log any failures
in scheduling the task, improving overall reliability.
2025-01-25 01:23:04 +08:00
032ff0552a feat: add jQuery and UI support
- Introduce jQuery and jQuery UI libraries
- Create an add_jquery.js for global availability
- Import active_admin.js to initialize the setup

These changes integrate jQuery and jQuery UI into the project, which
will facilitate more interactive user interface features. The jQuery
object is made globally accessible to use within other scripts.
2025-01-25 01:11:15 +08:00
3203face6b feat: add country filter in city admin
- Introduce a new filter for selecting cities by country in the
  ActiveAdmin interface.
- Update the ransackable attributes to include country_id for
  searchable functionality.

This change enhances the Admin UI by allowing easier access to
city data based on country, improving the user experience for
administrators.
2025-01-25 00:54:23 +08:00
8364d42759 feat: add country input to city form
- Added country input field to the city form for better user experience.
- Removed unused region and weather-related fields to declutter the form.

This change improves the data captured for cities and enhances the form's usability by focusing on relevant information.
2025-01-25 00:50:43 +08:00
9ce473dddb fix: increase sleep duration in weather arts worker
- Change SLEEP_DURATION from 60 seconds to 120 seconds

This change is made to improve resource management
and allow for a more efficient operation of the batch
processing tasks performed by the worker. It helps in
avoiding potential overload on system resources.
2025-01-25 00:41:29 +08:00
fe55437c96 refactor: update generation and sleep intervals
- Change generation interval from 6 hours to 24 hours
- Increase sleep duration from 3 seconds to 60 seconds

These adjustments aim to optimize the worker's performance and reduce resource consumption. This change reflects a shift towards longer generation cycles, potentially improving throughput.
2025-01-25 00:40:51 +08:00
ec3669249f style: update site title in application layout
- Change the displayed title from 'Today AI Weather Art' to 'Today AI Weather'.

This change refines the branding by removing the word 'Art', making the title more concise and focused. This is a minor modification that does not impact functionality.
2025-01-25 00:34:20 +08:00
f6270b1ad4 feat: add weather icons and update city view
- Introduce `weather_description_icon` and `weather_stat_icon`
  helper methods for displaying SVG icons based on weather
  conditions and statistics.
- Enhance the city show view by using these icons to display
  visual weather information such as temperature, wind, humidity,
  visibility, pressure, and cloud cover.
- Optimize the visual styling and layout of the weather stats
  and cards for better user experience.
2025-01-24 10:01:43 +08:00
9dd7044a77 style: update title in application layout
- Change title from 'AI Weather Art' to 'Today AI Weather Art'

This change enhances the clarity of the application by specifying that the art relates to today's weather. It does not affect any functionality but improves the user interface.
2025-01-24 09:15:51 +08:00
dd6bb9972c feat: implement ads.txt configuration
- Add ads.txt configuration file
- Update public/ads.txt with Google AdMob publisher ID

This feature allows us to set up Google AdMob in our app and earn revenue from ads.
It includes proper configuration of the ads.txt file for future ad networks.
2025-01-24 09:14:13 +08:00
23fc14af59 style: format priority values for consistency
- Clean up the seed file for cities in China
- Ensure all priority values have a consistent formatting style

This change enhances the readability of the data seeding code without altering the underlying logic or functionality of the application.
2025-01-24 09:09:57 +08:00
fedb954d34 chore: add Google AdSense script to layout
- Include async script for Google AdSense in the application layout
- Ensure script loads with crossorigin attribute

This change will allow the application to serve ads from Google, potentially generating revenue. The implementation is non-intrusive and maintains existing functionality.
2025-01-24 09:04:41 +08:00
7612dd6bd9 refactor: tidy up code style and remove unused fields
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
Docker / docker (push) Waiting to run
- Added a space in the array passed to `add_index` in the migration
- Removed unused columns `last_weather_fetch` and `last_image_generation` from the `cities` table
- Ensured consistent code style in the `GenerateWeatherArtWorker` and added required newline at the end of files

These changes improve code readability and maintainability while ensuring that
no unnecessary fields exist in the database schema.
2025-01-24 00:25:06 +08:00
b4af78aa77 feat: add logging and refactor image attachment
- Add logging to track the generation of weather art for each city.
- Refactor image attachment process to streamline the code by removing the
  separate method for attaching images.
- Ensure proper handling of the temporary file used for image processing.

These changes improve observability during the weather art generation
process and encapsulate the image attachment logic within the primary
method, reducing the overhead of a method call. The adjustments also
ensure that temporary files are managed correctly to prevent resource
leaks.
2025-01-24 00:23:09 +08:00
b05cf10017 refactor: simplify city weather generation logic
- Introduced constants for configuration settings such as generation interval, maximum duration, and sleep duration.
- Updated the `perform` method to utilize these constants for better readability and maintainability.
- Refactored the `perform` method in `GenerateWeatherArtWorker` to improve flow and error handling by creating separate methods for fetching weather data, generating prompts, images, and handling database transactions.
- Cleaned up city seeding data by removing unnecessary fields while maintaining required functionality.

These changes improve the overall readability of the code and make it easier to adjust the behavior of the workers in the future without digging through the logic.
2025-01-24 00:14:29 +08:00
06a861c639 refactor: clean up city model and adjust worker timing
- Implement caching methods for last weather fetch and image generation
- Adjust sleep duration in BatchGenerateWeatherArtsWorker from 10 seconds to 3 seconds
- Remove unused fields `last_weather_fetch` and `last_image_generation` from the cities table
- Add index on the weather_arts table for optimized querying

This refactor improves data retrieval performance for weather data
associated with cities. Caching reduces database load while the
worker modification allows for faster iterations in generating
weather arts without significantly impacting performance.
2025-01-23 23:59:48 +08:00
2cd23a6047 fix: update ownership permissions in Dockerfile
- Added 'public' directory to the chown command to ensure the
  proper ownership for runtime files.
- This change enhances the security by ensuring that all
  necessary directories are owned by the designated non-root
  user.

The previous behavior did not account for the 'public'
directory, which could lead to permission issues at runtime.
2025-01-23 19:53:50 +08:00
80a75d3fbb feat: add Google Analytics tracking code
- Include gtag.js for Google Analytics
- Setup dataLayer for tracking events
- Configure Google Analytics with unique ID

This commit integrates Google Analytics into the web
application to enable tracking of user interactions and
site usage. It sets up the necessary scripts and initializes
the tracking code with the provided unique ID, improving
analytics capabilities.
2025-01-23 19:49:59 +08:00
f477f205ab fix: update default host in sitemap generator and refresh sitemap on worker
- update default host in sitemap generator
- refresh sitemap on worker with new host

These changes allow the sitemap to be correctly generated for different environments and to handle the new host correctly, which was the motivation behind this update. No other side effects are expected.
2025-01-23 19:40:08 +08:00
1f47ba59c9 style: format code for consistency
- Ensure consistent use of double quotes for strings in the
  Gemfile and sitemap configuration files.
- Add spaces for better readability in array declarations
  within the RefreshSitemapWorker.

These changes improve the readability of the code without
changing any functionality. Adhering to a consistent coding
style helps maintainability and team collaboration.
2025-01-23 19:03:14 +08:00
6544f0247c chore: update robots.txt for better indexing
- Add disallow rules for /admin/ to prevent indexing
- Update sitemap URL to point to the correct domain

These changes improve site indexing by ensuring that sensitive
admin pages are not accessible to search engine crawlers
and updating the sitemap to reflect the accurate URL, helping
discovery and SEO efforts.
2025-01-23 19:03:00 +08:00
a0516f731c feat: add SEO meta tags and sitemap generation
- Introduced `SeoConcern` module to handle SEO meta tags
- Integrated `meta-tags` gem for customizable meta tags
- Created `RefreshSitemapWorker` to automate sitemap updates
- Added relevant meta tags in controllers for weather art and cities
- Configured sitemap generation settings

These changes improve the SEO of the application by ensuring that
pages have appropriate meta tags. Additionally, a sitemap is now
generated and refreshed daily, enhancing site visibility to search
engines.
2025-01-23 19:02:52 +08:00
18f751938f feat: update application layout
- Add language attribute to HTML tag
- Update meta viewport attribute for accessibility
- Include Plausible JavaScript tracking script for analytics

This change improves application accessibility by setting the language attribute for the HTML tag, and it enhances data collection capabilities by integrating Plausible analytics tracking.
2025-01-23 17:46:37 +08:00
2759646145 chore: update docker compose file
- Update compose.yaml to pull policy: always for production environment
- Added RAILS_ENV environment variable for production

Changes impact the overall system functionality by ensuring containers are always pulled from the latest images, and provide clear environment variables for the production environment.
2025-01-23 17:44:03 +08:00
665f6f29b6 refactor: change Sidekiq route to admin/tasks
- Update route for Sidekiq web interface from '/sidekiq'
  to '/admin/tasks'

This change improves the coherence of the application's routing
by placing the Sidekiq interface under a more descriptive
namespace. It consolidates admin-related tasks under a common
path, enhancing the organization of the routing structure.
2025-01-23 17:34:45 +08:00
bafb90f5fb style: format code style in Gemfile and controllers
- Adjust spacing around the quotes in the Gemfile
- Standardize spacing in the arts_controller for improved readability
- Modify routes file for consistent array formatting

These changes enhance the consistency of code style across the project without altering any functionality or behavior.
2025-01-23 17:33:01 +08:00
f33fb4d2ba feat: add pagination to cities and arts index
- Implement pagination for the cities index view.
- Add shared pagination partial to reduce code duplication.
- Modify arts index view to utilize the new pagination.
- Update cities controller to include pagination logic.

These updates improve usability by allowing better navigation through larger datasets, ensuring users can easily access and view items across multiple pages.
2025-01-23 17:30:05 +08:00
81 changed files with 1535 additions and 524 deletions

2
.gitignore vendored
View File

@ -38,3 +38,5 @@
/node_modules /node_modules
.idea .idea
public/sitemap.xml.gz

View File

@ -75,7 +75,7 @@ COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security # Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \ RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ 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 USER 1000:1000
# Entrypoint prepares the database. # Entrypoint prepares the database.

View File

@ -45,13 +45,18 @@ gem "devise", "~> 4.9"
gem "activeadmin", "~> 3.2" gem "activeadmin", "~> 3.2"
gem "friendly_id", "~> 5.5" gem "friendly_id", "~> 5.5"
gem 'kaminari', '~> 1.2' gem "kaminari", "~> 1.2"
gem "meta-tags", "~> 2.22"
gem "sitemap_generator", "~> 6.3"
gem "ahoy_matey", "~> 5.2"
# gem "whenever", "~> 1.0" # gem "whenever", "~> 1.0"
gem "ruby-openai", "~> 7.3" gem "ruby-openai", "~> 7.3"
gem "httparty", "~> 0.22.0" gem "httparty", "~> 0.22.0"
gem "down", "~> 5.4" gem "down", "~> 5.4"
gem "aws-sdk-s3", "~> 1.177" gem "aws-sdk-s3", "~> 1.178"
gem "sidekiq", "~> 7.3" gem "sidekiq", "~> 7.3"
gem "sidekiq-scheduler", "~> 5.0" gem "sidekiq-scheduler", "~> 5.0"

View File

@ -84,22 +84,26 @@ GEM
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
ahoy_matey (5.2.1)
activesupport (>= 6.1)
device_detector (>= 1)
safely_block (>= 0.4)
arbre (1.7.0) arbre (1.7.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
ruby2_keywords (>= 0.0.2) ruby2_keywords (>= 0.0.2)
ast (2.4.2) ast (2.4.2)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.1035.0) aws-partitions (1.1042.0)
aws-sdk-core (3.215.0) aws-sdk-core (3.217.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.96.0) aws-sdk-kms (1.97.0)
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.177.0) aws-sdk-s3 (1.178.0)
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0) aws-sigv4 (1.11.0)
@ -136,6 +140,7 @@ GEM
debug (1.10.0) debug (1.10.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
device_detector (1.1.3)
devise (4.9.4) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
@ -233,6 +238,8 @@ GEM
net-smtp net-smtp
marcel (1.0.4) marcel (1.0.4)
matrix (0.4.2) matrix (0.4.2)
meta-tags (2.22.1)
actionpack (>= 6.0.0, < 8.1)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.4) minitest (5.25.4)
msgpack (1.7.5) msgpack (1.7.5)
@ -383,6 +390,7 @@ GEM
rubyzip (2.4.1) rubyzip (2.4.1)
rufus-scheduler (3.9.2) rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1) fugit (~> 1.1, >= 1.11.1)
safely_block (0.4.1)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.28.0) selenium-webdriver (4.28.0)
base64 (~> 0.2) base64 (~> 0.2)
@ -400,6 +408,8 @@ GEM
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8) sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3) tilt (>= 1.4.0, < 3)
sitemap_generator (6.3.0)
builder (~> 3.0)
solid_cable (3.0.5) solid_cable (3.0.5)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
@ -481,7 +491,8 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
activeadmin (~> 3.2) activeadmin (~> 3.2)
aws-sdk-s3 (~> 1.177) ahoy_matey (~> 5.2)
aws-sdk-s3 (~> 1.178)
bootsnap bootsnap
brakeman brakeman
capybara capybara
@ -495,6 +506,7 @@ DEPENDENCIES
jsbundling-rails jsbundling-rails
kamal kamal
kaminari (~> 1.2) kaminari (~> 1.2)
meta-tags (~> 2.22)
pg (~> 1.5) pg (~> 1.5)
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
@ -504,6 +516,7 @@ DEPENDENCIES
selenium-webdriver selenium-webdriver
sidekiq (~> 7.3) sidekiq (~> 7.3)
sidekiq-scheduler (~> 5.0) sidekiq-scheduler (~> 5.0)
sitemap_generator (~> 6.3)
solid_cable solid_cable
solid_cache solid_cache
solid_queue solid_queue

31
app/admin/ahoy_events.rb Normal file
View File

@ -0,0 +1,31 @@
ActiveAdmin.register Ahoy::Event do
# 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

View 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

35
app/admin/ahoy_visits.rb Normal file
View File

@ -0,0 +1,35 @@
ActiveAdmin.register Ahoy::Visit do
# 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

View File

@ -36,18 +36,17 @@ ActiveAdmin.register City do
filter :name filter :name
filter :active filter :active
filter :country, as: :select
form do |f| form do |f|
f.inputs do f.inputs do
f.input :active f.input :active
f.input :name f.input :name
f.input :country
f.input :latitude f.input :latitude
f.input :longitude f.input :longitude
f.input :priority f.input :priority
f.input :timezone f.input :timezone
f.input :region
f.input :last_weather_fetch
f.input :last_image_generation
end end
f.actions f.actions
end end

View File

@ -1,4 +1,9 @@
ActiveAdmin.register Country do ActiveAdmin.register Country do
controller do
def find_resource
scoped_collection.friendly.find(params[:id])
end
end
# See permitted parameters documentation: # See permitted parameters documentation:
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters # https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
# #

View File

@ -11,6 +11,53 @@ ActiveAdmin.register_page "Dashboard" do
end end
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
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
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. # Here is an example of a simple dashboard with columns and panels.
# #
# columns do # columns do

View File

@ -1,2 +0,0 @@
//= require active_admin/base
import "@activeadmin/activeadmin";

View 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

View File

@ -1,7 +1,15 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include SeoConcern
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern allow_browser versions: :modern
before_action :set_locale before_action :set_locale
after_action :track_action
protected
def track_action
ahoy.track "Viewed Application", request.path_parameters
end
private private

View File

@ -3,14 +3,14 @@ class ArtsController < ApplicationController
@regions = Region.all @regions = Region.all
@current_region = Region.find(params[:region]) if params[:region].present? @current_region = Region.find(params[:region]) if params[:region].present?
@weather_arts = WeatherArt.includes(city: [:country, { country: :region }]) @weather_arts = WeatherArt.includes(city: [ :country, { country: :region } ])
if @current_region if @current_region
@weather_arts = @weather_arts.joins(city: :country) @weather_arts = @weather_arts.joins(city: :country)
.where(countries: { region_id: @current_region.id }) .where(countries: { region_id: @current_region.id })
end end
@weather_arts = if params[:sort] == 'oldest' @weather_arts = if params[:sort] == "oldest"
@weather_arts.order(created_at: :asc) @weather_arts.order(created_at: :asc)
else else
@weather_arts.order(created_at: :desc) @weather_arts.order(created_at: :desc)

View File

@ -1,8 +1,7 @@
class CitiesController < ApplicationController class CitiesController < ApplicationController
def index def index
@cities = City.all.order(:name)
@regions = Region.includes(:countries).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] if params[:region]
@current_region = Region.friendly.find(params[:region]) @current_region = Region.friendly.find(params[:region])
@ -13,9 +12,31 @@ class CitiesController < ApplicationController
@current_country = Country.friendly.find(params[:country]) @current_country = Country.friendly.find(params[:country])
@cities = @cities.by_country(@current_country.id) @cities = @cities.by_country(@current_country.id)
end 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 end
def show def show
@city = City.friendly.find(params[:id]) @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
end end

View File

@ -2,5 +2,10 @@ class HomeController < ApplicationController
def index def index
@latest_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(6) @latest_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(6)
@featured_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(5) @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
end end

View 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

View File

@ -2,5 +2,25 @@ class WeatherArtsController < ApplicationController
def show def show
@city = City.friendly.find(params[:city_id]) @city = City.friendly.find(params[:city_id])
@weather_art = @city.weather_arts.friendly.find(params[:slug]) @weather_art = @city.weather_arts.friendly.find(params[:slug])
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
end end

View File

@ -1,2 +1,24 @@
module ApplicationHelper 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 end

View File

@ -0,0 +1,2 @@
module SitemapsHelper
end

View File

@ -1,2 +1,51 @@
module WeatherArtsHelper 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 end

View File

@ -0,0 +1,6 @@
import './add_jquery'
import "jquery/dist/jquery"
import "jquery-ui/dist/jquery-ui"
import "jquery-ujs"
import "@activeadmin/activeadmin"

View File

@ -0,0 +1,4 @@
import jquery from 'jquery'
import $ from 'jquery'
window.jQuery = jquery
window.$ = $

View File

@ -6,3 +6,4 @@ import "@fontsource/raleway/400.css";
import "@fontsource/raleway/600.css"; import "@fontsource/raleway/600.css";
import "./controllers" import "./controllers"
import "./active_admin"

14
app/models/ahoy/event.rb Normal file
View 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
View 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

View File

@ -5,6 +5,9 @@ class City < ApplicationRecord
has_many :weather_arts, dependent: :destroy 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 delegate :region, to: :country
validates :name, presence: true validates :name, presence: true
@ -17,6 +20,41 @@ class City < ApplicationRecord
scope :by_country, ->(country_id) { where(country_id: country_id) } scope :by_country, ->(country_id) { where(country_id: country_id) }
scope :active, -> { where(active: true) } 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->>'city_id')::integer = cities.id
AND ahoy_events.properties->>'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->>'city_id')::integer = cities.id
AND 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")
end
}
def to_s def to_s
name name
end end
@ -45,10 +83,32 @@ class City < ApplicationRecord
end end
def self.ransackable_attributes(auth_object = nil) 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 end
def latest_weather_art def latest_weather_art
weather_arts.order(weather_date: :desc).first weather_arts.order(weather_date: :desc).first
end 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->>'event_type' = 'city_view' AND (properties->>'city_id')::integer = ?", self.id).count
end
end
end end

View File

@ -5,9 +5,28 @@ class WeatherArt < ApplicationRecord
belongs_to :city belongs_to :city
has_one_attached :image has_one_attached :image
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 :weather_date, presence: true
validates :city_id, 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->>'weather_art_id')::integer = weather_arts.id
AND 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")
end
}
def should_generate_new_friendly_id? def should_generate_new_friendly_id?
weather_date_changed? || city_id_changed? || super weather_date_changed? || city_id_changed? || super
end end
@ -23,4 +42,12 @@ class WeatherArt < ApplicationRecord
def self.ransackable_attributes(auth_object = nil) 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" ] [ "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 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->>'event_type' = 'weather_art_view' AND (properties->>'weather_art_id')::integer = ?", self.id).count
end
end
end end

View File

@ -101,21 +101,17 @@
</div> </div>
</div> </div>
<!-- 图片网格 -->
<div class="container mx-auto px-4 pb-16"> <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"> <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| %> <% @weather_arts.each do |art| %>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 group overflow-hidden"> <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"> <figure class="relative aspect-square overflow-hidden">
<% if art.image.attached? %> <% if art.image.attached? %>
<%= image_tag art.image, <%= image_tag art.image,
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %> 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 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="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"> <div class="text-white space-y-2">
<h3 class="text-xl font-display font-bold"> <h3 class="text-xl font-display font-bold">
@ -168,20 +164,9 @@
<% end %> <% end %>
</div> </div>
<!-- 分页 --> <%= render 'shared/pagination',
<% if @weather_arts.total_pages > 1 %> collection: @weather_arts,
<div class="flex justify-center mt-12"> collection_name: 'weather arts' %>
<div class="btn-group">
<%= link_to_prev_page @weather_arts, "Previous",
class: "btn btn-outline #{'btn-disabled' unless @weather_arts.prev_page}" %>
<% @weather_arts.total_pages.times do |i| %>
<%= link_to i + 1, arts_path(page: i + 1, region: params[:region], sort: params[:sort]),
class: "btn btn-outline #{'btn-active' if @weather_arts.current_page == i + 1}" %>
<% end %>
<%= link_to_next_page @weather_arts, "Next",
class: "btn btn-outline #{'btn-disabled' unless @weather_arts.next_page}" %>
</div> </div>
</div> </div>
<% end %>
</div>
</div> </div>

View File

@ -12,7 +12,6 @@
</div> </div>
<% end %> <% end %>
<!-- 标题内容 -->
<div class="relative pt-24 pb-32"> <div class="relative pt-24 pb-32">
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<div class="max-w-3xl mx-auto text-center space-y-6"> <div class="max-w-3xl mx-auto text-center space-y-6">
@ -38,13 +37,10 @@
</div> </div>
</div> </div>
<!-- 筛选导航 - 使用下拉菜单 -->
<div class="sticky top-16 z-20 bg-base-100/95 backdrop-blur-sm border-b border-base-200"> <div class="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="container mx-auto px-4">
<div class="py-3 flex items-center justify-between gap-4"> <div class="py-3 flex items-center justify-between gap-4">
<!-- 左侧筛选器 -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- 区域选择下拉框 -->
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-ghost gap-2"> <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"> <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> </ul>
</div> </div>
<!-- 国家选择下拉框 (如果选择了区域) -->
<% if @current_region %> <% if @current_region %>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-ghost gap-2"> <button class="btn btn-ghost gap-2">
@ -104,7 +99,6 @@
<% end %> <% end %>
</div> </div>
<!-- 右侧结果统计 -->
<div class="text-sm text-base-content/70"> <div class="text-sm text-base-content/70">
<%= @cities.count %> <%= 'city'.pluralize(@cities.count) %> <%= @cities.count %> <%= 'city'.pluralize(@cities.count) %>
<% if @current_country %> <% if @current_country %>
@ -117,10 +111,16 @@
</div> </div>
</div> </div>
<!-- 城市网格 -->
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<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"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<%= render partial: 'city', collection: @cities %> <%= render partial: 'city', collection: @cities %>
</div> </div>
<%= render 'shared/pagination',
collection: @cities,
collection_name: 'cities' %>
</div>
</div> </div>
</div> </div>

View File

@ -1,91 +1,149 @@
<div class="min-h-screen"> <div class="relative min-h-screen bg-base-200">
<!-- 城市头部信息 --> <!-- 背景效果 -->
<section class="relative h-[40vh] overflow-hidden">
<% if @city.latest_weather_art&.image&.attached? %> <% if @city.latest_weather_art&.image&.attached? %>
<div class="fixed inset-0 -z-10">
<%= image_tag @city.latest_weather_art.image, <%= image_tag @city.latest_weather_art.image,
class: "w-full h-full object-cover" %> class: "absolute w-full h-full object-cover scale-110 filter blur-2xl opacity-25" %>
<div class="absolute inset-0 bg-gradient-to-t from-base-100 via-base-100/50 to-transparent"></div> <div class="absolute inset-0 bg-gradient-to-b from-base-200/90 to-base-200/70 backdrop-blur-md"></div>
</div>
<% end %> <% end %>
<div class="absolute inset-0 flex items-center"> <!-- 主要内容 -->
<div class="container mx-auto px-4"> <div class="relative z-10">
<div class="max-w-4xl"> <!-- 返回导航 -->
<div class="flex items-center space-x-4 mb-4"> <div class="container mx-auto px-4 py-6">
<%= link_to cities_path, <%= link_to cities_path,
class: "btn btn-ghost btn-circle" do %> 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-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
Back to Cities
<% end %> <% end %>
<h1 class="text-4xl md:text-5xl font-display font-bold"><%= @city.localized_name %></h1>
</div> </div>
<div class="stats bg-base-100/80 backdrop-blur-sm shadow"> <!-- 城市信息头部 -->
<div class="stat"> <div class="container mx-auto px-4 mb-12">
<div class="stat-title">Latitude</div> <div class="max-w-4xl mx-auto text-center space-y-6">
<div class="stat-value text-2xl"><%= @city.latitude %></div> <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>
<div class="stat"> <div class="badge badge-lg badge-secondary gap-2">
<div class="stat-title">Longitude</div> <%= Time.current.in_time_zone(@city.timezone).strftime("%Y-%m-%d %H:%M") %>
<div class="stat-value text-2xl"><%= @city.longitude %></div> </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="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-value text-xl">
<%= @city.latitude %>°N,
<%= @city.longitude %>°E
</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>
<div class="stat">
<div class="stat-title">Weather Arts</div>
<div class="stat-value text-2xl"><%= @city.weather_arts.count %></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>
</div> </div>
</div> </div>
</div>
</section>
<!-- 天气艺术历史记录 --> <!-- 天气艺术历史记录 -->
<section class="container mx-auto px-4 py-16"> <div class="container mx-auto px-4 pb-16">
<div class="space-y-8"> <div class="max-w-7xl mx-auto space-y-8">
<h2 class="text-3xl font-display font-bold">Weather Art History</h2> <!-- 标题和更新时间 -->
<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>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <!-- 天气艺术卡片网格 -->
<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| %> <% @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"> <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-[4/3] overflow-hidden"> <figure class="relative aspect-video overflow-hidden">
<% if art.image.attached? %> <% if art.image.attached? %>
<%= image_tag art.image, <%= image_tag art.image,
class: "w-full h-full object-cover transform hover:scale-105 transition-transform duration-500" %> 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/70 to-transparent">
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/60 to-transparent"> <div class="flex items-center justify-between text-white">
<div class="text-white">
<div class="text-2xl font-bold"><%= art.temperature %>°C</div> <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 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> </div>
</div>
<% end %>
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h3 class="card-title font-display"><%= art.description %></h3> <h3 class="card-title font-display">
<%= weather_description_icon(art.description) %>
<%= art.description %>
</h3>
<div class="grid grid-cols-2 gap-4 my-4 text-sm"> <div class="grid grid-cols-2 gap-4 my-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <%= weather_stat_icon("humidity") %>
<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> <span>Humidity: <%= art.humidity %>%</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 opacity-70" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <%= weather_stat_icon("wind") %>
<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> <span>Wind: <%= art.wind_scale %></span>
</div> </div>
</div> </div>
<div class="card-actions justify-end"> <%= link_to city_weather_art_path(@city, art),
<%= link_to "View Details", city_weather_art_path(@city, art), class: "btn btn-primary btn-block" do %>
class: "btn btn-primary btn-outline" %> View Details
</div> <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>
</div> </div>
<% end %> <% end %>
</div> </div>
</div> </div>
</section> </div>
</div>
</div> </div>

View File

@ -58,7 +58,7 @@
</div> </div>
</section> </section>
</div> </div>
<div class="text-center mt-12"> <div class="text-center mt-12 mb-12">
<%= link_to arts_path, class: "btn btn-primary btn-lg gap-2" do %> <%= link_to arts_path, class: "btn btn-primary btn-lg gap-2" do %>
View All Weather Arts 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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@ -1,10 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title><%= content_for(:title) || "Today Ai Weather" %></title> <title><%= content_for(:title) || "Today Ai Weather" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="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 %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
@ -20,6 +33,23 @@
<%# Includes all stylesheet files in app/assets/stylesheets %> <%# Includes all stylesheet files in app/assets/stylesheets %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= 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> </head>
<body class="min-h-screen bg-base-100 font-sans"> <body class="min-h-screen bg-base-100 font-sans">
@ -28,7 +58,7 @@
<div class="container mx-auto"> <div class="container mx-auto">
<div class="flex-1"> <div class="flex-1">
<%= link_to root_path, class: "text-2xl font-display font-bold hover:text-primary transition-colors" do %> <%= link_to root_path, class: "text-2xl font-display font-bold hover:text-primary transition-colors" do %>
AI Weather Art Today AI Weather
<% end %> <% end %>
</div> </div>
<div class="flex-none"> <div class="flex-none">
@ -44,10 +74,25 @@
</main> </main>
<!-- 页脚 --> <!-- 页脚 -->
<footer class="footer footer-center p-8 bg-base-200 text-base-content mt-16"> <footer class="footer footer-center p-8 bg-base-200 text-base-content">
<div> <div class="container mx-auto flex flex-col gap-2">
<p class="font-display">Copyright © 2024 - All rights reserved by AI Weather Art</p> <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> </div>
</footer> </footer>
</body> </body>
</html> </html>

View 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 #{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 #{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-ghost hover:bg-primary/5" %>
<% if start_page > 2 %>
<button class="join-item btn 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-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-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-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-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 #{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 #{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 %>

View File

@ -0,0 +1,11 @@
<!-- app/views/weather_arts/_weather_stat.html.erb -->
<div class="stat bg-base-200/50 backdrop-blur-sm rounded-box hover:bg-base-300/50 transition-all duration-300">
<div class="flex items-center gap-2 mb-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="<%= icon %>" />
</svg>
<div class="stat-title font-medium"><%= title %></div>
</div>
<div class="stat-value text-2xl"><%= value %></div>
<div class="stat-desc mt-1"><%= desc %></div>
</div>

View File

@ -1,8 +1,25 @@
<div class="min-h-screen"> <% content_for :head do %>
<script type="application/ld+json">
<%= weather_art_schema(@weather_art) %>
</script>
<% end %>
<div class="relative min-h-screen bg-base-200">
<!-- 背景图片 -->
<% if @weather_art.image.attached? %>
<div class="fixed inset-0 -z-10">
<%= image_tag @weather_art.image,
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="relative z-10">
<!-- 返回导航 --> <!-- 返回导航 -->
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-6">
<%= link_to city_path(@weather_art.city), <%= link_to city_path(@weather_art.city),
class: "btn btn-ghost gap-2" do %> 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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
@ -10,72 +27,129 @@
<% end %> <% end %>
</div> </div>
<!-- 主要内容 -->
<div class="container mx-auto px-4 pb-16"> <div class="container mx-auto px-4 pb-16">
<div class="max-w-6xl mx-auto"> <div class="max-w-6xl mx-auto">
<!-- 头部信息 --> <!-- 头部信息 -->
<div class="text-center space-y-4 mb-12"> <div class="text-center space-y-4 mb-12">
<h1 class="text-4xl md:text-5xl font-display font-bold"> <div class="inline-flex items-center gap-2 text-sm font-medium px-4 py-2 rounded-full bg-base-100/50 backdrop-blur-sm">
<%= @weather_art.city.name %> <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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
<%= @weather_art.city.full_name %>
</div>
<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">
Weather Art
</span>
</h1> </h1>
<p class="text-xl text-base-content/70">
<div class="flex flex-wrap justify-center items-center gap-3">
<div class="badge badge-lg badge-primary gap-2">
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<%= @weather_art.weather_date.strftime("%B %d, %Y") %> <%= @weather_art.weather_date.strftime("%B %d, %Y") %>
</p> </div>
<div class="badge badge-lg badge-secondary gap-2">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<%= @weather_art.weather_date.strftime("%H:%M") %>
</div>
</div>
</div> </div>
<!-- 主要卡片 --> <!-- 主要卡片 -->
<div class="card lg:card-side bg-base-100 shadow-2xl"> <div class="card lg:card-side bg-base-100/80 backdrop-blur-md shadow-2xl">
<figure class="lg:w-1/2 relative aspect-square lg:aspect-auto"> <figure class="lg:w-1/2 relative aspect-square lg:aspect-auto group">
<% if @weather_art.image.attached? %> <% if @weather_art.image.attached? %>
<%= image_tag @weather_art.image, <%= image_tag @weather_art.image,
class: "w-full h-full object-cover" %> class: "w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" %>
<div class="absolute inset-0 bg-gradient-to-t from-base-100/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<% end %> <% end %>
</figure> </figure>
<div class="card-body lg:w-1/2"> <div class="card-body lg:w-1/2">
<h2 class="card-title font-display text-2xl mb-6"> <div class="prose max-w-none mb-8">
<h2 class="card-title font-display text-3xl mb-4 flex items-center gap-3">
<%= weather_description_icon(@weather_art.description) %>
<%= @weather_art.description %> <%= @weather_art.description %>
</h2> </h2>
<div class="divider"></div>
</div>
<!-- 天气数据网格 --> <!-- 天气数据网格 -->
<div class="grid grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="stat bg-base-200 rounded-box"> <div class="stat bg-base-200/50 backdrop-blur-sm rounded-box hover:bg-base-300/50 transition-all duration-300">
<div class="stat-title">Temperature</div> <div class="flex items-center gap-2 mb-2">
<div class="stat-value"><%= @weather_art.temperature %>°C</div> <%= weather_stat_icon("temperature") %>
<div class="stat-desc">Feels like <%= @weather_art.feeling_temp %>°C</div> <div class="stat-title font-medium">Temperature</div>
</div>
<div class="stat-value text-2xl"><%= @weather_art.temperature %>°C</div>
<div class="stat-desc mt-1">Feels like <%= @weather_art.feeling_temp %>°C</div>
</div> </div>
<div class="stat bg-base-200 rounded-box"> <div class="stat bg-base-200/50 backdrop-blur-sm rounded-box hover:bg-base-300/50 transition-all duration-300">
<div class="stat-title">Wind</div> <div class="flex items-center gap-2 mb-2">
<div class="stat-value"><%= @weather_art.wind_scale %></div> <%= weather_stat_icon("wind") %>
<div class="stat-desc"><%= @weather_art.wind_speed %> km/h</div> <div class="stat-title font-medium">Wind</div>
</div>
<div class="stat-value text-2xl"><%= @weather_art.wind_scale %></div>
<div class="stat-desc mt-1"><%= @weather_art.wind_speed %> km/h</div>
</div> </div>
<div class="stat bg-base-200 rounded-box"> <div class="stat bg-base-200/50 backdrop-blur-sm rounded-box hover:bg-base-300/50 transition-all duration-300">
<div class="stat-title">Humidity</div> <div class="flex items-center gap-2 mb-2">
<div class="stat-value"><%= @weather_art.humidity %>%</div> <%= weather_stat_icon("humidity") %>
<div class="stat-title font-medium">Humidity</div>
</div>
<div class="stat-value text-2xl"><%= @weather_art.humidity %>%</div>
<div class="stat-desc mt-1">Relative humidity</div>
</div> </div>
<div class="stat bg-base-200 rounded-box"> <div class="stat bg-base-200/50 backdrop-blur-sm rounded-box hover:bg-base-300/50 transition-all duration-300">
<div class="stat-title">Visibility</div> <div class="flex items-center gap-2 mb-2">
<div class="stat-value"><%= @weather_art.visibility %> km</div> <%= weather_stat_icon("visibility") %>
<div class="stat-title font-medium">Visibility</div>
</div>
<div class="stat-value text-2xl"><%= @weather_art.visibility %> km</div>
<div class="stat-desc mt-1">Clear view distance</div>
</div> </div>
<div class="stat bg-base-200 rounded-box"> <div class="stat bg-base-200/50 backdrop-blur-sm rounded-box hover:bg-base-300/50 transition-all duration-300">
<div class="stat-title">Pressure</div> <div class="flex items-center gap-2 mb-2">
<div class="stat-value"><%= @weather_art.pressure %> hPa</div> <%= weather_stat_icon("pressure") %>
<div class="stat-title font-medium">Pressure</div>
</div>
<div class="stat-value text-2xl"><%= @weather_art.pressure %> hPa</div>
<div class="stat-desc mt-1">Atmospheric pressure</div>
</div> </div>
<div class="stat bg-base-200 rounded-box"> <div class="stat bg-base-200/50 backdrop-blur-sm rounded-box hover:bg-base-300/50 transition-all duration-300">
<div class="stat-title">Cloud Cover</div> <div class="flex items-center gap-2 mb-2">
<div class="stat-value"><%= @weather_art.cloud %>%</div> <%= weather_stat_icon("cloud") %>
<div class="stat-title font-medium">Cloud Cover</div>
</div>
<div class="stat-value text-2xl"><%= @weather_art.cloud %>%</div>
<div class="stat-desc mt-1">Sky coverage</div>
</div> </div>
</div> </div>
<!-- AI Prompt --> <!-- AI Prompt -->
<div class="mt-8 bg-base-200 p-6 rounded-box"> <div class="mt-8">
<h3 class="font-display font-bold text-lg mb-3">AI Prompt</h3> <div class="bg-base-200/50 backdrop-blur-sm p-6 rounded-box border border-base-300">
<p class="text-base-content/70"><%= @weather_art.prompt %></p> <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/70 leading-relaxed">
<%= @weather_art.prompt %>
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,34 +1,36 @@
class BatchGenerateWeatherArtsWorker class BatchGenerateWeatherArtsWorker
include Sidekiq::Worker include Sidekiq::Worker
GENERATION_INTERVAL = 24.hours
MAX_DURATION = 50.minutes
SLEEP_DURATION = 120.seconds
def perform(*args) def perform(*args)
start_time = Time.current start_time = Time.current
max_duration = 50.minutes
cities_to_process = get_eligible_cities cities_to_process = get_eligible_cities
cities_to_process.each do |city| cities_to_process.each do |city|
break if Time.current - start_time > max_duration break if Time.current - start_time > MAX_DURATION
Rails.logger.info "Generating weather art for #{city.name}"
# GenerateWeatherArtJob.perform_now(city)
GenerateWeatherArtWorker.perform_async(city.id) GenerateWeatherArtWorker.perform_async(city.id)
sleep 10.seconds sleep SLEEP_DURATION
end end
end end
private private
def get_eligible_cities def get_eligible_cities
cutoff_time = Time.current - GENERATION_INTERVAL
City.active City.active
.where(active: true) .joins("LEFT JOIN (
.where("last_weather_fetch IS NULL OR last_weather_fetch < ?", Date.today) SELECT city_id, MAX(created_at) as last_generation_time
# .select { |city| early_morning_in_timezone?(city.timezone) } 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 end
# def early_morning_in_timezone?(timezone)
# return false if timezone.blank?
# time = Time.current.in_time_zone(timezone)
# time.hour == 2
# end
end end

View 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

View File

@ -1,46 +1,68 @@
class GenerateWeatherArtWorker class GenerateWeatherArtWorker
include Sidekiq::Worker include Sidekiq::Worker
def perform(*args) def perform(city_id)
city_id = args[0] @city = City.find(city_id)
city = City.find(city_id)
return if city.last_weather_fetch&.today?
weather_service = WeatherService.new weather_data = fetch_weather_data
ai_service = AiService.new
# 获取天气数据
weather_data = weather_service.get_weather(city.latitude, city.longitude)
return unless weather_data return unless weather_data
# 生成提示词 prompt = generate_prompt(weather_data)
prompt = ai_service.generate_prompt(city, weather_data)
return unless prompt return unless prompt
# 生成图像 image_url = generate_image(prompt)
image_url = ai_service.generate_image(prompt)
return unless image_url return unless image_url
# 创建天气艺术记录 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
private
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_art = city.weather_arts.create!(
weather_date: Date.today, weather_date: Date.today,
**weather_data, prompt: prompt,
prompt: prompt **weather_data
) )
# 下载并附加图像
tempfile = Down.download(image_url) tempfile = Down.download(image_url)
weather_art.image.attach( weather_art.image.attach(
io: tempfile, io: File.open(tempfile.path),
filename: "#{city.country.name}-#{city.name.parameterize}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.png" filename: generate_filename,
content_type: "image/png"
) )
# 更新城市状态 weather_art
city.update!( end
last_weather_fetch: Time.current, ensure
last_image_generation: Time.current if tempfile
) tempfile.close
rescue => e tempfile.unlink
Rails.logger.error "Error generating weather art for #{city.name}: #{e.message}" end
end
def generate_filename
"#{city.country.name}-#{city.name.parameterize}-#{Time.current.strftime('%Y%m%d-%H%M%S')}.png"
end end
end end

View 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

View File

@ -3,6 +3,7 @@ services:
image: songtianlun/today_ai_weather:latest image: songtianlun/today_ai_weather:latest
#ports: #ports:
# - "3000:3000" # - "3000:3000"
pull_policy: always
environment: environment:
- RAILS_ENV=production - RAILS_ENV=production
- DATABASE_URL=postgresql://postgres:${PG_PASSWORD}@db:5432/db - DATABASE_URL=postgresql://postgres:${PG_PASSWORD}@db:5432/db

View 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

View 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

View 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

View 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

View File

@ -5,7 +5,7 @@ Sidekiq.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1") } config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1") }
config.logger.level = Logger::INFO config.logger.level = Logger::INFO
config.on(:startup) do config.on(:startup) do
schedule_file = "config/sidekiq.yml" schedule_file = "config/sidekiq_scheduler.yml"
if File.exist?(schedule_file) if File.exist?(schedule_file)
Sidekiq::Scheduler.enabled = true Sidekiq::Scheduler.enabled = true
Sidekiq::Scheduler.dynamic = true Sidekiq::Scheduler.dynamic = true

View File

@ -6,7 +6,7 @@ Rails.application.routes.draw do
resources :cities, only: [ :index, :show ] do resources :cities, only: [ :index, :show ] do
resources :weather_arts, path: "weather", only: [ :show ], param: :slug resources :weather_arts, path: "weather", only: [ :show ], param: :slug
end end
resources :arts, only: [:index] resources :arts, only: [ :index ]
# namespace :admin do # namespace :admin do
# resources :cities # resources :cities
@ -18,12 +18,14 @@ Rails.application.routes.draw do
get "cities/index" get "cities/index"
get "cities/show" get "cities/show"
get "home/index" get "home/index"
get "sitemaps/*path", to: "sitemaps#show", format: false
devise_for :admin_users, ActiveAdmin::Devise.config devise_for :admin_users, ActiveAdmin::Devise.config
ActiveAdmin.routes(self) ActiveAdmin.routes(self)
# mount Sidekiq::Web => '/sidekiq' # mount Sidekiq::Web => '/sidekiq'
authenticate :admin_user do authenticate :admin_user do
mount Sidekiq::Web => "/sidekiq" mount Sidekiq::Web => "/admin/tasks"
end end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

View File

@ -1,5 +0,0 @@
batch_generate_weather:
cron: '0 */1 * * *'
class: BatchGenerateWeatherArtsWorker
description: "Generate weather arts every 2 hours"
enabled: true

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

View File

@ -19,11 +19,11 @@ build:
# bucket: your_own_bucket-<%= Rails.env %> # bucket: your_own_bucket-<%= Rails.env %>
amazon_dev: amazon_dev:
service: S3 service: S3
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :access_key_id)) %> 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_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :secret_access_key)) %> secret_access_key: <%= ENV.fetch("AWS_DEV_SECRET_ACCESS_KEY_ID", Rails.application.credentials.dig(:aws_dev, :secret_access_key)) %>
region: <%= ENV.fetch("AWS_REGION", "wnam") %> region: <%= ENV.fetch("AWS_DEV_REGION", "wnam") %>
bucket: <%= ENV.fetch("AWS_BUCKET", Rails.application.credentials.dig(:aws_dev, :bucket)) %> bucket: <%= ENV.fetch("AWS_DEV_BUCKET", Rails.application.credentials.dig(:aws_dev, :bucket)) %>
endpoint: <%= ENV.fetch("AWS_ENDPOINT", Rails.application.credentials.dig(:aws_dev, :endpoint)) %> endpoint: <%= ENV.fetch("AWS_DEV_ENDPOINT", Rails.application.credentials.dig(:aws_dev, :endpoint)) %>
amazon: amazon:
service: S3 service: S3

View File

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

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

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "active_admin_comments", force: :cascade do |t|
t.string "namespace" t.string "namespace"
t.text "body" 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 t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
end 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| create_table "cities", force: :cascade do |t|
t.string "name" t.string "name"
t.float "latitude" t.float "latitude"
@ -72,8 +114,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_22_053220) do
t.boolean "active" t.boolean "active"
t.integer "priority" t.integer "priority"
t.string "timezone" t.string "timezone"
t.datetime "last_weather_fetch"
t.datetime "last_image_generation"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "slug" 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "slug" 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 ["city_id"], name: "index_weather_arts_on_city_id"
t.index ["slug"], name: "index_weather_arts_on_slug", unique: true t.index ["slug"], name: "index_weather_arts_on_slug", unique: true
end end

View File

@ -8,9 +8,7 @@ City.create!([
country: australia, country: australia,
timezone: 'Australia/Sydney', timezone: 'Australia/Sydney',
active: true, active: true,
priority: 80, priority: 80
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Melbourne', name: 'Melbourne',
@ -19,8 +17,6 @@ City.create!([
country: australia, country: australia,
timezone: 'Australia/Melbourne', timezone: 'Australia/Melbourne',
active: true, active: true,
priority: 75, priority: 75
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: bangladesh, country: bangladesh,
timezone: 'Asia/Dhaka', timezone: 'Asia/Dhaka',
active: true, active: true,
priority: 85, priority: 85
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: brazil, country: brazil,
timezone: 'America/Sao_Paulo', timezone: 'America/Sao_Paulo',
active: true, active: true,
priority: 80, priority: 80
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -6,7 +6,5 @@ City.create!(
priority: 50, priority: 50,
country: canada, country: canada,
timezone: 'America/Toronto', timezone: 'America/Toronto',
active: true, active: true
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
) )

View File

@ -8,9 +8,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Beijing', name: 'Beijing',
@ -19,9 +17,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Shenzhen', name: 'Shenzhen',
@ -30,9 +26,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Guangzhou', name: 'Guangzhou',
@ -41,9 +35,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Chengdu', name: 'Chengdu',
@ -52,9 +44,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Tianjin', name: 'Tianjin',
@ -63,9 +53,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Wuhan', name: 'Wuhan',
@ -74,9 +62,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Dongguan', name: 'Dongguan',
@ -85,9 +71,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Chongqing', name: 'Chongqing',
@ -96,9 +80,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: "Xi'an", name: "Xi'an",
@ -107,9 +89,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Hangzhou', name: 'Hangzhou',
@ -118,9 +98,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Foshan', name: 'Foshan',
@ -129,9 +107,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Nanjing', name: 'Nanjing',
@ -140,9 +116,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Hong Kong', name: 'Hong Kong',
@ -151,9 +125,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Hong_Kong', timezone: 'Asia/Hong_Kong',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Shenyang', name: 'Shenyang',
@ -162,9 +134,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Zhengzhou', name: 'Zhengzhou',
@ -173,9 +143,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Qingdao', name: 'Qingdao',
@ -184,9 +152,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Suzhou', name: 'Suzhou',
@ -195,9 +161,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Changsha', name: 'Changsha',
@ -206,9 +170,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Jinan', name: 'Jinan',
@ -217,9 +179,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Kunming', name: 'Kunming',
@ -228,9 +188,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Harbin', name: 'Harbin',
@ -239,9 +197,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Shijiazhuang', name: 'Shijiazhuang',
@ -250,9 +206,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Hefei', name: 'Hefei',
@ -261,9 +215,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Dalian', name: 'Dalian',
@ -272,9 +224,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Xiamen', name: 'Xiamen',
@ -283,9 +233,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Nanning', name: 'Nanning',
@ -294,9 +242,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Changchun', name: 'Changchun',
@ -305,9 +251,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Taiyuan', name: 'Taiyuan',
@ -316,9 +260,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'New Taipei City', name: 'New Taipei City',
@ -327,9 +269,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Taipei', timezone: 'Asia/Taipei',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Guiyang', name: 'Guiyang',
@ -338,9 +278,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Wuxi', name: 'Wuxi',
@ -349,9 +287,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Shantou', name: 'Shantou',
@ -360,9 +296,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Ürümqi', name: 'Ürümqi',
@ -371,9 +305,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Zhongshan', name: 'Zhongshan',
@ -382,9 +314,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Ningbo', name: 'Ningbo',
@ -393,9 +323,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Fuzhou', name: 'Fuzhou',
@ -404,9 +332,7 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Nanchang', name: 'Nanchang',
@ -415,8 +341,6 @@ City.create!([
country: china, country: china,
timezone: 'Asia/Shanghai', timezone: 'Asia/Shanghai',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: egypt, country: egypt,
timezone: 'Africa/Cairo', timezone: 'Africa/Cairo',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: france, country: france,
timezone: 'Europe/Paris', timezone: 'Europe/Paris',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,9 +8,7 @@ City.create!([
country: germany, country: germany,
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Berlin', name: 'Berlin',
@ -19,8 +17,6 @@ City.create!([
country: germany, country: germany,
timezone: 'Europe/Berlin', timezone: 'Europe/Berlin',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,9 +8,7 @@ City.create!([
country: india, country: india,
timezone: 'Asia/Kolkata', timezone: 'Asia/Kolkata',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Bengaluru', name: 'Bengaluru',
@ -19,8 +17,6 @@ City.create!([
country: india, country: india,
timezone: 'Asia/Kolkata', timezone: 'Asia/Kolkata',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,9 +8,7 @@ City.create!([
country: japan, country: japan,
timezone: 'Asia/Tokyo', timezone: 'Asia/Tokyo',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Yokohama', name: 'Yokohama',
@ -19,8 +17,6 @@ City.create!([
country: japan, country: japan,
timezone: 'Asia/Tokyo', timezone: 'Asia/Tokyo',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: mexico, country: mexico,
timezone: 'America/Mexico_City', timezone: 'America/Mexico_City',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: nigeria, country: nigeria,
timezone: 'Africa/Lagos', timezone: 'Africa/Lagos',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: pakistan, country: pakistan,
timezone: 'Asia/Karachi', timezone: 'Asia/Karachi',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,9 +8,7 @@ City.create!([
country: russia, country: russia,
timezone: 'Europe/Moscow', timezone: 'Europe/Moscow',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Sankt Petersburg', name: 'Sankt Petersburg',
@ -19,8 +17,6 @@ City.create!([
country: russia, country: russia,
timezone: 'Europe/Moscow', timezone: 'Europe/Moscow',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: saudi_arabia, country: saudi_arabia,
timezone: 'Asia/Riyadh', timezone: 'Asia/Riyadh',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: singapore, country: singapore,
timezone: 'Asia/Singapore', timezone: 'Asia/Singapore',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: south_korea, country: south_korea,
timezone: 'Asia/Seoul', timezone: 'Asia/Seoul',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: thailand, country: thailand,
timezone: 'Asia/Bangkok', timezone: 'Asia/Bangkok',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,9 +8,7 @@ City.create!([
country: turkey, country: turkey,
timezone: 'Europe/Istanbul', timezone: 'Europe/Istanbul',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Ankara', name: 'Ankara',
@ -19,8 +17,6 @@ City.create!([
country: turkey, country: turkey,
timezone: 'Europe/Istanbul', timezone: 'Europe/Istanbul',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,8 +8,6 @@ City.create!([
country: uk, country: uk,
timezone: 'Europe/London', timezone: 'Europe/London',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,9 +8,7 @@ City.create!([
country: usa, country: usa,
timezone: 'America/Los_Angeles', timezone: 'America/Los_Angeles',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Chicago', name: 'Chicago',
@ -19,9 +17,7 @@ City.create!([
country: usa, country: usa,
timezone: 'America/Chicago', timezone: 'America/Chicago',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'New York City', name: 'New York City',
@ -30,9 +26,7 @@ City.create!([
country: usa, country: usa,
timezone: 'America/New_York', timezone: 'America/New_York',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Los Angeles', name: 'Los Angeles',
@ -41,8 +35,6 @@ City.create!([
country: usa, country: usa,
timezone: 'America/Los_Angeles', timezone: 'America/Los_Angeles',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -8,9 +8,7 @@ City.create!([
country: vietnam, country: vietnam,
timezone: 'Asia/Ho_Chi_Minh', timezone: 'Asia/Ho_Chi_Minh',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
}, },
{ {
name: 'Hanoi', name: 'Hanoi',
@ -19,8 +17,6 @@ City.create!([
country: vietnam, country: vietnam,
timezone: 'Asia/Ho_Chi_Minh', timezone: 'Asia/Ho_Chi_Minh',
active: true, active: true,
priority: 100, priority: 100
last_weather_fetch: 10.days.ago,
last_image_generation: 10.days.ago
} }
]) ])

View File

@ -16,6 +16,8 @@
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo-rails": "^8.0.12", "@hotwired/turbo-rails": "^8.0.12",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jquery": "^3.7.1",
"jquery-ui": "^1.14.1",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"sass": "^1.83.4", "sass": "^1.83.4",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"

1
public/ads.txt Normal file
View File

@ -0,0 +1 @@
google.com, pub-7296634171837358, DIRECT, f08c47fec0942fa0

View File

@ -1 +1,7 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /admin
Sitemap: https://todayaiweather.com/sitemap.xml.gz

View File

@ -0,0 +1,7 @@
require "test_helper"
class SitemapsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@ -729,12 +729,19 @@ jquery-ui@^1.13.3:
dependencies: dependencies:
jquery ">=1.12.0 <5.0.0" jquery ">=1.12.0 <5.0.0"
jquery-ui@^1.14.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.14.1.tgz#ba342ea3ffff662b787595391f607d923313e040"
integrity sha512-DhzsYH8VeIvOaxwi+B/2BCsFFT5EGjShdzOcm5DssWjtcpGWIMsn66rJciDA6jBruzNiLf1q0KvwMoX1uGNvnQ==
dependencies:
jquery ">=1.12.0 <5.0.0"
jquery-ujs@^1.2.2: jquery-ujs@^1.2.2:
version "1.2.3" version "1.2.3"
resolved "https://registry.npmjs.org/jquery-ujs/-/jquery-ujs-1.2.3.tgz" resolved "https://registry.npmjs.org/jquery-ujs/-/jquery-ujs-1.2.3.tgz"
integrity sha512-59wvfx5vcCTHMeQT1/OwFiAj+UffLIwjRIoXdpO7Z7BCFGepzq9T9oLVeoItjTqjoXfUrHJvV7QU6pUR+UzOoA== integrity sha512-59wvfx5vcCTHMeQT1/OwFiAj+UffLIwjRIoXdpO7Z7BCFGepzq9T9oLVeoItjTqjoXfUrHJvV7QU6pUR+UzOoA==
"jquery@>=1.12.0 <5.0.0", jquery@^3.4.1: "jquery@>=1.12.0 <5.0.0", jquery@^3.4.1, jquery@^3.7.1:
version "3.7.1" version "3.7.1"
resolved "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz" resolved "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz"
integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==