Compare commits
No commits in common. "main" and "rm" have entirely different histories.
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@ -22,6 +22,22 @@ jobs:
|
||||
- name: Scan for common Rails security vulnerabilities using static analysis
|
||||
run: bin/brakeman --no-pager
|
||||
|
||||
scan_js:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Scan for security vulnerabilities in JavaScript dependencies
|
||||
run: bin/importmap audit
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@ -48,7 +64,7 @@ jobs:
|
||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git node-gyp pkg-config python-is-python3 google-chrome-stable
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git pkg-config google-chrome-stable
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
48
.github/workflows/docker-dev.yml
vendored
48
.github/workflows/docker-dev.yml
vendored
@ -1,48 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 获取完整的 git history 以便生成正确的 tag
|
||||
-
|
||||
name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
echo "VERSION=$(git describe --dirty --always --tags --abbrev=7)" >> $GITHUB_OUTPUT
|
||||
-
|
||||
name: Login to ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:dev
|
51
.github/workflows/docker-main.yml
vendored
51
.github/workflows/docker-main.yml
vendored
@ -1,51 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 获取完整的 git history 以便生成正确的 tag
|
||||
-
|
||||
name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
echo "VERSION=$(git describe --dirty --always --tags --abbrev=7)" >> $GITHUB_OUTPUT
|
||||
-
|
||||
name: Login to ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:main
|
||||
${{ env.REGISTRY }}/${{github.actor}}/${{ github.event.repository.name }}:${{ steps.get_version.outputs.VERSION }}
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,6 +37,5 @@
|
||||
!/app/assets/builds/.keep
|
||||
|
||||
/node_modules
|
||||
.idea
|
||||
|
||||
public/sitemap.xml.gz
|
||||
.idea
|
||||
|
@ -1 +0,0 @@
|
||||
20.17.0
|
@ -1 +1 @@
|
||||
ruby-3.3.5
|
||||
3.3.5
|
||||
|
44
Dockerfile
44
Dockerfile
@ -2,8 +2,8 @@
|
||||
# check=error=true
|
||||
|
||||
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
|
||||
# docker build -t today_ai_weather .
|
||||
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name today_ai_weather today_ai_weather
|
||||
# docker build -t sample_app .
|
||||
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name sample_app sample_app
|
||||
|
||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||
|
||||
@ -16,7 +16,7 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 libpq5 redis-tools && \
|
||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Set production environment
|
||||
@ -28,19 +28,28 @@ ENV RAILS_ENV="production" \
|
||||
# Throw-away build stage to reduce size of final image
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems and node modules
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y build-essential git node-gyp pkg-config python-is-python3 libpq-dev && \
|
||||
apt-get install --no-install-recommends -y build-essential git pkg-config && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Install JavaScript dependencies
|
||||
ARG NODE_VERSION=20.17.0
|
||||
ARG YARN_VERSION=1.22.22
|
||||
ENV PATH=/usr/local/node/bin:$PATH
|
||||
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
|
||||
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
|
||||
npm install -g yarn@$YARN_VERSION && \
|
||||
rm -rf /tmp/node-build-master
|
||||
# 安装 NVM
|
||||
ENV NVM_DIR="/usr/local/nvm"
|
||||
ENV NODE_VERSION="18.20.4"
|
||||
|
||||
RUN mkdir -p $NVM_DIR && \
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash && \
|
||||
. $NVM_DIR/nvm.sh && \
|
||||
nvm install $NODE_VERSION && \
|
||||
nvm alias default $NODE_VERSION && \
|
||||
nvm use default
|
||||
|
||||
# 设置环境变量
|
||||
ENV NODE_PATH="$NVM_DIR/v$NODE_VERSION/lib/node_modules"
|
||||
ENV PATH="$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH"
|
||||
|
||||
# 安装 yarn
|
||||
RUN npm install -g yarn
|
||||
|
||||
# Install application gems
|
||||
COPY Gemfile Gemfile.lock ./
|
||||
@ -48,10 +57,6 @@ RUN bundle install && \
|
||||
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
|
||||
bundle exec bootsnap precompile --gemfile
|
||||
|
||||
# Install node modules
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
@ -59,10 +64,9 @@ COPY . .
|
||||
RUN bundle exec bootsnap precompile app/ lib/
|
||||
|
||||
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
|
||||
RUN RAILS_BUILD=1 SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||
|
||||
|
||||
RUN rm -rf node_modules
|
||||
|
||||
|
||||
# Final stage for app image
|
||||
@ -75,7 +79,7 @@ COPY --from=build /rails /rails
|
||||
# Run and own only the runtime files as a non-root user for security
|
||||
RUN groupadd --system --gid 1000 rails && \
|
||||
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
|
||||
chown -R rails:rails db log storage tmp public
|
||||
chown -R rails:rails db log storage tmp
|
||||
USER 1000:1000
|
||||
|
||||
# Entrypoint prepares the database.
|
||||
|
48
Gemfile
48
Gemfile
@ -8,17 +8,20 @@ gem "propshaft"
|
||||
gem "sqlite3", ">= 2.1"
|
||||
# Use the Puma web server [https://github.com/puma/puma]
|
||||
gem "puma", ">= 5.0"
|
||||
# Bundle and transpile JavaScript [https://github.com/rails/jsbundling-rails]
|
||||
gem "jsbundling-rails"
|
||||
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
|
||||
gem "importmap-rails"
|
||||
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
|
||||
gem "turbo-rails"
|
||||
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
|
||||
gem "stimulus-rails"
|
||||
# Bundle and process CSS [https://github.com/rails/cssbundling-rails]
|
||||
gem "cssbundling-rails"
|
||||
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
|
||||
gem "jbuilder"
|
||||
|
||||
gem "bcrypt", "~> 3.1"
|
||||
|
||||
gem "faker", "~> 3.5"
|
||||
gem "kaminari", "~> 1.2"
|
||||
|
||||
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
|
||||
# gem "bcrypt", "~> 3.1.7"
|
||||
|
||||
@ -41,28 +44,6 @@ gem "thruster", require: false
|
||||
|
||||
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
|
||||
# gem "image_processing", "~> 1.2"
|
||||
gem "devise", "~> 4.9"
|
||||
gem "activeadmin", "~> 3.2"
|
||||
gem "friendly_id", "~> 5.5"
|
||||
|
||||
gem "kaminari", "~> 1.2"
|
||||
|
||||
gem "meta-tags", "~> 2.22"
|
||||
gem "sitemap_generator", "~> 6.3"
|
||||
|
||||
gem "ahoy_matey", "~> 5.2"
|
||||
|
||||
# gem "whenever", "~> 1.0"
|
||||
gem "ruby-openai", "~> 7.3"
|
||||
gem "httparty", "~> 0.22.0"
|
||||
gem "down", "~> 5.4"
|
||||
gem "aws-sdk-s3", "~> 1.177"
|
||||
gem "sidekiq", "~> 7.3"
|
||||
gem "sidekiq-scheduler", "~> 5.0"
|
||||
|
||||
gem "image_processing", "~> 1.13"
|
||||
gem "ruby-vips", "~> 2.2"
|
||||
gem "mini_magick", "~> 4.13.2"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
@ -78,14 +59,21 @@ end
|
||||
group :development do
|
||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||
gem "web-console"
|
||||
end
|
||||
|
||||
group :production do
|
||||
gem "pg", "~> 1.5"
|
||||
gem "guard", "~> 2.19"
|
||||
end
|
||||
|
||||
group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
gem "minitest-reporters"
|
||||
gem "rails-controller-testing"
|
||||
end
|
||||
|
||||
gem "jsbundling-rails", "~> 1.3"
|
||||
|
||||
group :production do
|
||||
gem "pg", "~> 1.5"
|
||||
end
|
||||
|
||||
gem "tailwindcss-rails", "~> 3.2"
|
||||
|
279
Gemfile.lock
279
Gemfile.lock
@ -44,16 +44,6 @@ GEM
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activeadmin (3.2.5)
|
||||
arbre (~> 1.2, >= 1.2.1)
|
||||
csv
|
||||
formtastic (>= 3.1)
|
||||
formtastic_i18n (>= 0.4)
|
||||
inherited_resources (~> 1.7)
|
||||
jquery-rails (>= 4.2)
|
||||
kaminari (>= 1.2.1)
|
||||
railties (>= 6.1)
|
||||
ransack (>= 4.0)
|
||||
activejob (8.0.1)
|
||||
activesupport (= 8.0.1)
|
||||
globalid (>= 0.3.6)
|
||||
@ -84,30 +74,8 @@ GEM
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ahoy_matey (5.3.0)
|
||||
activesupport (>= 7)
|
||||
device_detector (>= 1)
|
||||
safely_block (>= 0.4)
|
||||
arbre (1.7.0)
|
||||
activesupport (>= 3.0.0)
|
||||
ruby2_keywords (>= 0.0.2)
|
||||
ansi (1.5.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1044.0)
|
||||
aws-sdk-core (3.217.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.97.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.179.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
@ -130,40 +98,22 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
crass (1.0.6)
|
||||
cssbundling-rails (1.4.1)
|
||||
railties (>= 6.0.0)
|
||||
csv (3.3.2)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
device_detector (1.1.3)
|
||||
devise (4.9.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
dotenv (3.1.7)
|
||||
down (5.4.2)
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.1)
|
||||
ed25519 (1.3.0)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faraday (2.12.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.17.1-aarch64-linux-gnu)
|
||||
ffi (1.17.1-aarch64-linux-musl)
|
||||
ffi (1.17.1-arm-linux-gnu)
|
||||
@ -172,46 +122,36 @@ GEM
|
||||
ffi (1.17.1-x86_64-darwin)
|
||||
ffi (1.17.1-x86_64-linux-gnu)
|
||||
ffi (1.17.1-x86_64-linux-musl)
|
||||
formtastic (5.0.0)
|
||||
actionpack (>= 6.0.0)
|
||||
formtastic_i18n (0.7.0)
|
||||
friendly_id (5.5.1)
|
||||
activerecord (>= 4.0.0)
|
||||
formatador (1.1.0)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
has_scope (0.8.2)
|
||||
actionpack (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.7)
|
||||
guard (2.19.1)
|
||||
formatador (>= 0.2.4)
|
||||
listen (>= 2.7, < 4.0)
|
||||
logger (~> 1.6)
|
||||
lumberjack (>= 1.0.12, < 2.0)
|
||||
nenv (~> 0.1)
|
||||
notiffany (~> 0.0)
|
||||
ostruct (~> 0.6)
|
||||
pry (>= 0.13.0)
|
||||
shellany (~> 0.0)
|
||||
thor (>= 0.18.1)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.13.0)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
inherited_resources (1.14.0)
|
||||
actionpack (>= 6.0)
|
||||
has_scope (>= 0.6)
|
||||
railties (>= 6.0)
|
||||
responders (>= 2)
|
||||
importmap-rails (2.1.0)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.0)
|
||||
irb (1.15.1)
|
||||
pp (>= 0.6.0)
|
||||
irb (1.14.3)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.13.0)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
jmespath (1.6.2)
|
||||
jquery-rails (4.6.0)
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
jsbundling-rails (1.3.1)
|
||||
railties (>= 6.0.0)
|
||||
json (2.9.1)
|
||||
@ -238,11 +178,15 @@ GEM
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
language_server-protocol (3.17.0.4)
|
||||
logger (1.6.5)
|
||||
loofah (2.24.0)
|
||||
language_server-protocol (3.17.0.3)
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.4)
|
||||
loofah (2.23.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
lumberjack (1.2.10)
|
||||
mail (2.8.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
@ -250,25 +194,24 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
meta-tags (2.22.1)
|
||||
actionpack (>= 6.0.0, < 8.1)
|
||||
mini_magick (4.13.2)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
minitest-reporters (1.7.1)
|
||||
ansi
|
||||
builder
|
||||
minitest (>= 5.0)
|
||||
ruby-progressbar
|
||||
msgpack (1.7.5)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.5)
|
||||
nenv (0.3.0)
|
||||
net-imap (0.5.4)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-scp (4.1.0)
|
||||
net-scp (4.0.0)
|
||||
net-ssh (>= 2.6.5, < 8.0.0)
|
||||
net-sftp (4.0.0)
|
||||
net-ssh (>= 5.0.0, < 8.0.0)
|
||||
@ -276,53 +219,55 @@ GEM
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.2-aarch64-linux-gnu)
|
||||
nokogiri (1.18.1-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-aarch64-linux-musl)
|
||||
nokogiri (1.18.1-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-gnu)
|
||||
nokogiri (1.18.1-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm-linux-musl)
|
||||
nokogiri (1.18.1-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-arm64-darwin)
|
||||
nokogiri (1.18.1-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-darwin)
|
||||
nokogiri (1.18.1-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-gnu)
|
||||
nokogiri (1.18.1-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.2-x86_64-linux-musl)
|
||||
nokogiri (1.18.1-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
notiffany (0.1.3)
|
||||
nenv (~> 0.1)
|
||||
shellany (~> 0.0)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.9)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
propshaft (1.1.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.3)
|
||||
pry (0.15.2)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
psych (5.2.2)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
puma (6.6.0)
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.9)
|
||||
rack-session (2.1.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack (2.2.10)
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (8.0.1)
|
||||
actioncable (= 8.0.1)
|
||||
actionmailbox (= 8.0.1)
|
||||
@ -337,6 +282,10 @@ GEM
|
||||
activesupport (= 8.0.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.1)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@ -354,40 +303,34 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
ransack (4.2.1)
|
||||
activerecord (>= 6.1.5)
|
||||
activesupport (>= 6.1.5)
|
||||
i18n
|
||||
rdoc (6.11.0)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rdoc (6.10.0)
|
||||
psych (>= 4.0.0)
|
||||
redis-client (0.23.2)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.6.0)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.4.0)
|
||||
rubocop (1.71.1)
|
||||
rubocop (1.70.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.38.0)
|
||||
rubocop-ast (1.37.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.36.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-performance (1.23.1)
|
||||
rubocop-performance (1.23.0)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.29.1)
|
||||
rubocop-rails (2.28.0)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
@ -397,39 +340,17 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-openai (7.3.1)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safely_block (0.4.1)
|
||||
rubyzip (2.3.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.28.0)
|
||||
selenium-webdriver (4.27.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sidekiq (7.3.8)
|
||||
base64
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
sidekiq-scheduler (5.0.6)
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 6, < 8)
|
||||
tilt (>= 1.4.0, < 3)
|
||||
sitemap_generator (6.3.0)
|
||||
builder (~> 3.0)
|
||||
solid_cable (3.0.7)
|
||||
shellany (0.0.1)
|
||||
solid_cable (3.0.5)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
@ -438,7 +359,7 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.1.3)
|
||||
solid_queue (1.1.2)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@ -462,13 +383,20 @@ GEM
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.2)
|
||||
tailwindcss-rails (3.2.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby
|
||||
tailwindcss-ruby (3.4.17-aarch64-linux)
|
||||
tailwindcss-ruby (3.4.17-arm-linux)
|
||||
tailwindcss-ruby (3.4.17-arm64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-darwin)
|
||||
tailwindcss-ruby (3.4.17-x86_64-linux)
|
||||
thor (1.3.2)
|
||||
thruster (0.1.10)
|
||||
thruster (0.1.10-aarch64-linux)
|
||||
thruster (0.1.10-arm64-darwin)
|
||||
thruster (0.1.10-x86_64-darwin)
|
||||
thruster (0.1.10-x86_64-linux)
|
||||
tilt (2.6.0)
|
||||
timeout (0.4.3)
|
||||
turbo-rails (2.0.11)
|
||||
actionpack (>= 6.0.0)
|
||||
@ -480,16 +408,14 @@ GEM
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.2)
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
@ -509,41 +435,32 @@ PLATFORMS
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
activeadmin (~> 3.2)
|
||||
ahoy_matey (~> 5.2)
|
||||
aws-sdk-s3 (~> 1.177)
|
||||
bcrypt (~> 3.1)
|
||||
bootsnap
|
||||
brakeman
|
||||
capybara
|
||||
cssbundling-rails
|
||||
debug
|
||||
devise (~> 4.9)
|
||||
down (~> 5.4)
|
||||
friendly_id (~> 5.5)
|
||||
httparty (~> 0.22.0)
|
||||
image_processing (~> 1.13)
|
||||
faker (~> 3.5)
|
||||
guard (~> 2.19)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
jsbundling-rails
|
||||
jsbundling-rails (~> 1.3)
|
||||
kamal
|
||||
kaminari (~> 1.2)
|
||||
meta-tags (~> 2.22)
|
||||
mini_magick (~> 4.13.2)
|
||||
minitest-reporters
|
||||
pg (~> 1.5)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.0.1)
|
||||
rails-controller-testing
|
||||
rubocop-rails-omakase
|
||||
ruby-openai (~> 7.3)
|
||||
ruby-vips (~> 2.2)
|
||||
selenium-webdriver
|
||||
sidekiq (~> 7.3)
|
||||
sidekiq-scheduler (~> 5.0)
|
||||
sitemap_generator (~> 6.3)
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
sqlite3 (>= 2.1)
|
||||
stimulus-rails
|
||||
tailwindcss-rails (~> 3.2)
|
||||
thruster
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
|
73
Guardfile
Normal file
73
Guardfile
Normal file
@ -0,0 +1,73 @@
|
||||
# A sample Guardfile
|
||||
# More info at https://github.com/guard/guard#readme
|
||||
|
||||
## Uncomment and set this to only include directories you want to watch
|
||||
# directories %w(app lib config test spec features) \
|
||||
# .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}
|
||||
|
||||
## Note: if you are using the `directories` clause above and you are not
|
||||
## watching the project directory ('.'), then you will want to move
|
||||
## the Guardfile to a watched dir and symlink it back, e.g.
|
||||
#
|
||||
# $ mkdir config
|
||||
# $ mv Guardfile config/
|
||||
# $ ln -s config/Guardfile .
|
||||
#
|
||||
# and, you'll have to watch "config/Guardfile" instead of "Guardfile"
|
||||
|
||||
require 'active_support/core_ext/string' # Defines the matching rules for Guard.
|
||||
guard :minitest, spring: "bin/rails test", all_on_start: false do
|
||||
watch(%r{^test/(.*)/?(.*)_test\.rb$})
|
||||
watch('test/test_helper.rb') { 'test' }
|
||||
watch('config/routes.rb') { interface_tests }
|
||||
watch(%r{app/views/layouts/*}) { interface_tests }
|
||||
watch(%r{âpp/models/(.*?)\.rb$}) do |matches|
|
||||
[ "test/models/#{matches[1]}_test.rb", "test/integration/microposts_interface_test.rb" ]
|
||||
end
|
||||
watch(%r{^test/fixtures/(.*?)\.yml$}) do |matches|
|
||||
"test/models/#{matches[1].singularize}_test.rb"
|
||||
end
|
||||
watch(%r{âpp/mailers/(.*?)\.rb$}) do |matches|
|
||||
"test/mailers/#{matches[1]}_test.rb"
|
||||
end
|
||||
watch(%r{âpp/views/(.*)_mailer/.*$}) do |matches|
|
||||
"test/mailers/#{matches[1]}_mailer_test.rb"
|
||||
end
|
||||
watch(%r{âpp/controllers/(.*?)_controller\.rb$}) do |matches|
|
||||
resource_tests(matches[1])
|
||||
end
|
||||
watch(%r{âpp/views/([^/]*?)/.*\.html\.erb$}) do |matches|
|
||||
[ "test/controllers/#{matches[1]}_controller_test.rb" ] + integration_tests(matches[1])
|
||||
end
|
||||
watch(%r{âpp/helpers/(.*?)_helper\.rb$}) do |matches| integration_tests(matches[1])
|
||||
end
|
||||
watch('app/views/layouts/application.html.erb') do 'test/integration/site_layout_test.rb'
|
||||
end
|
||||
watch('app/helpers/sessions_helper.rb') do integration_tests << 'test/helpers/sessions_helper_test.rb'
|
||||
end
|
||||
watch('app/controllers/sessions_controller.rb') do [ 'test/controllers/sessions_controller_test.rb', 'test/integration/users_login_test.rb' ]
|
||||
end
|
||||
watch('app/controllers/account_activations_controller.rb') do 'test/integration/users_signup_test.rb'
|
||||
end
|
||||
watch(%r{app/views/users/*}) do resource_tests('users') + [ 'test/integration/microposts_interface_test.rb' ]
|
||||
end
|
||||
end # Returns the integration tests corresponding to the given resource.
|
||||
|
||||
|
||||
def integration_tests(resource = :all)
|
||||
if resource == :all
|
||||
Dir["test/integration/*"]
|
||||
else
|
||||
Dir["test/integration/#{resource}_*.rb"]
|
||||
end
|
||||
end # Returns all tests that hit the interface.
|
||||
def
|
||||
interface_tests
|
||||
integration_tests << "test/controllers"
|
||||
end # Returns the controller tests corresponding to the given resource.
|
||||
def
|
||||
controller_test(resource) "test/controllers/#{resource}_controller_test.rb"
|
||||
end # Returns all tests for the given resource.
|
||||
def
|
||||
resource_tests(resource) integration_tests(resource) << controller_test(resource)
|
||||
end
|
@ -1,3 +1,3 @@
|
||||
web: env RUBY_DEBUG_OPEN=true bin/rails server
|
||||
js: yarn build --watch
|
||||
css: yarn build:css --watch
|
||||
css: bin/rails tailwindcss:watch
|
||||
|
@ -1,28 +0,0 @@
|
||||
ActiveAdmin.register AdminUser do
|
||||
menu label: "AdminUser Manager", parent: "系统管理"
|
||||
permit_params :email, :password, :password_confirmation
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :email
|
||||
column :current_sign_in_at
|
||||
column :sign_in_count
|
||||
column :created_at
|
||||
actions
|
||||
end
|
||||
|
||||
filter :email
|
||||
filter :current_sign_in_at
|
||||
filter :sign_in_count
|
||||
filter :created_at
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :email
|
||||
f.input :password
|
||||
f.input :password_confirmation
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
@ -1,83 +0,0 @@
|
||||
ActiveAdmin.register_page "Ahoy Dashboard" do
|
||||
menu label: "总览", parent: "数据统计", priority: 1
|
||||
page_action :toggle_city_status, method: :post do
|
||||
city = City.find(params[:city_id])
|
||||
city.update(active: !city.active)
|
||||
redirect_back(fallback_location: admin_dashboard_path, notice: "城市状态已更新")
|
||||
end
|
||||
|
||||
content title: "总览" do
|
||||
columns do
|
||||
column do
|
||||
panel "访问统计" do
|
||||
para "总访问量: #{Ahoy::Visit.count}"
|
||||
para "总事件数: #{Ahoy::Event.count}"
|
||||
para "独立访客数: #{Ahoy::Visit.distinct.count(:visitor_token)}"
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门城市" do
|
||||
table_for City.by_popularity.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门天气艺术" do
|
||||
table_for WeatherArt.by_popularity.limit(10) do
|
||||
column("作品") { |art| link_to(art.to_s, admin_weather_art_path(art)) }
|
||||
column("访问量") { |art| art.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
columns do
|
||||
column do
|
||||
panel "最冷门活跃城市" do
|
||||
table_for City.least_popular_active.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
# column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
column("操作") { |city|
|
||||
button_to "停用",
|
||||
admin_ahoy_dashboard_toggle_city_status_path(city_id: city.id),
|
||||
method: :post,
|
||||
data: { confirm: "确定要停用 #{city.name} 吗?" }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门未活跃城市" do
|
||||
table_for City.most_popular_inactive.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
column("所属区域") { |city| city.country.region.name }
|
||||
column("操作") { |city|
|
||||
button_to "激活",
|
||||
admin_ahoy_dashboard_toggle_city_status_path(city_id: city.id),
|
||||
method: :post,
|
||||
data: { confirm: "确定要激活 #{city.name} 吗?" }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 添加一个事件列表面板
|
||||
panel "最近事件" do
|
||||
table_for Ahoy::Event.order(time: :desc).limit(10) do
|
||||
column :time
|
||||
column :name
|
||||
column :properties
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -1,32 +0,0 @@
|
||||
ActiveAdmin.register Ahoy::Event do
|
||||
menu label: "事件统计", parent: "数据统计"
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
# permit_params :visit_id, :user_id, :name, :properties, :time
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:visit_id, :user_id, :name, :properties, :time]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
# menu priority: 101, label: "事件统计"
|
||||
|
||||
actions :index
|
||||
|
||||
index do
|
||||
column :id
|
||||
column :name
|
||||
column :time
|
||||
column :properties
|
||||
column :user_id
|
||||
end
|
||||
|
||||
filter :name
|
||||
filter :time
|
||||
filter :properties
|
||||
end
|
@ -1,34 +0,0 @@
|
||||
# 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
|
@ -1,36 +0,0 @@
|
||||
ActiveAdmin.register Ahoy::Visit do
|
||||
menu label: "访客统计", parent: "数据统计"
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
# permit_params :visit_token, :visitor_token, :user_id, :ip, :user_agent, :referrer, :referring_domain, :landing_page, :browser, :os, :device_type, :country, :region, :city, :latitude, :longitude, :utm_source, :utm_medium, :utm_term, :utm_content, :utm_campaign, :app_version, :os_version, :platform, :started_at
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:visit_token, :visitor_token, :user_id, :ip, :user_agent, :referrer, :referring_domain, :landing_page, :browser, :os, :device_type, :country, :region, :city, :latitude, :longitude, :utm_source, :utm_medium, :utm_term, :utm_content, :utm_campaign, :app_version, :os_version, :platform, :started_at]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
|
||||
# menu priority: 100, label: "访问统计"
|
||||
|
||||
actions :index
|
||||
|
||||
index do
|
||||
column :id
|
||||
column :visitor_token
|
||||
column :ip
|
||||
column :user_agent
|
||||
column :started_at
|
||||
column :city
|
||||
column :country
|
||||
column :region
|
||||
end
|
||||
|
||||
filter :started_at
|
||||
filter :city
|
||||
filter :country
|
||||
end
|
@ -1,54 +0,0 @@
|
||||
ActiveAdmin.register City do
|
||||
menu label: "City Manager", parent: "系统管理"
|
||||
controller do
|
||||
def find_resource
|
||||
scoped_collection.friendly.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
permit_params :name, :country_id, :latitude, :longitude, :active, :priority, :timezone, :last_weather_fetch, :last_image_generation, :slug
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:name, :country, :latitude, :longitude, :active, :priority, :timezone, :region, :last_weather_fetch, :last_image_generation, :slug]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name
|
||||
column :country
|
||||
column :region do |city|
|
||||
city.region
|
||||
end
|
||||
column :latitude
|
||||
column :longitude
|
||||
column :active
|
||||
actions
|
||||
end
|
||||
|
||||
filter :name
|
||||
filter :active
|
||||
filter :country, as: :select
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :active
|
||||
f.input :name
|
||||
f.input :country
|
||||
f.input :latitude
|
||||
f.input :longitude
|
||||
f.input :priority
|
||||
f.input :timezone
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
@ -1,42 +0,0 @@
|
||||
ActiveAdmin.register Country do
|
||||
menu label: "Country Manager", parent: "系统管理"
|
||||
controller do
|
||||
def find_resource
|
||||
scoped_collection.friendly.find(params[:id])
|
||||
end
|
||||
end
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
permit_params :name, :code, :region_id
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:name, :code, :slug, :region_id]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name
|
||||
column :code
|
||||
column :region
|
||||
column :cities_count do |country|
|
||||
country.cities.count
|
||||
end
|
||||
actions
|
||||
end
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :region
|
||||
f.input :name
|
||||
f.input :code
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
@ -1,94 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
ActiveAdmin.register_page "Dashboard" do
|
||||
menu priority: 1, label: proc { I18n.t("active_admin.dashboard") }
|
||||
|
||||
content title: proc { I18n.t("active_admin.dashboard") } do
|
||||
div class: "blank_slate_container", id: "dashboard_default_message" do
|
||||
span class: "blank_slate" do
|
||||
span I18n.t("active_admin.dashboard_welcome.welcome")
|
||||
small I18n.t("active_admin.dashboard_welcome.call_to_action")
|
||||
end
|
||||
end
|
||||
|
||||
columns do
|
||||
column do
|
||||
panel "访问统计" do
|
||||
para "总访问量: #{Ahoy::Visit.count}"
|
||||
para "总事件数: #{Ahoy::Event.count}"
|
||||
para "独立访客数: #{Ahoy::Visit.distinct.count(:visitor_token)}"
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门城市" do
|
||||
table_for City.by_popularity.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门天气艺术" do
|
||||
table_for WeatherArt.by_popularity.limit(10) do
|
||||
column("作品") { |art| link_to(art.to_s, admin_weather_art_path(art)) }
|
||||
column("访问量") { |art| art.view_count }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
columns do
|
||||
column do
|
||||
panel "最冷门活跃城市" do
|
||||
table_for City.least_popular_active.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
# column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
column do
|
||||
panel "热门未活跃城市" do
|
||||
table_for City.most_popular_inactive.limit(10) do
|
||||
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
|
||||
column("访问量") { |city| city.view_count }
|
||||
column("状态") { |city| status_tag(city.active? ? "活跃" : "停用") }
|
||||
column("所属区域") { |city| city.country.region.name }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 添加一个事件列表面板
|
||||
panel "最近事件" do
|
||||
table_for Ahoy::Event.order(time: :desc).limit(10) do
|
||||
column :time
|
||||
column :name
|
||||
column :properties
|
||||
end
|
||||
end
|
||||
|
||||
# Here is an example of a simple dashboard with columns and panels.
|
||||
#
|
||||
# columns do
|
||||
# column do
|
||||
# panel "Recent Posts" do
|
||||
# ul do
|
||||
# Post.recent(5).map do |post|
|
||||
# li link_to(post.title, admin_post_path(post))
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
|
||||
# column do
|
||||
# panel "Info" do
|
||||
# para "Welcome to ActiveAdmin."
|
||||
# end
|
||||
# end
|
||||
# end
|
||||
end # content
|
||||
end
|
@ -1,30 +0,0 @@
|
||||
ActiveAdmin.register Region do
|
||||
menu label: "Region Manager", parent: "系统管理"
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
permit_params :name, :code
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:name, :code, :slug]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :name
|
||||
column :code
|
||||
column :countries_count do |region|
|
||||
region.countries.count
|
||||
end
|
||||
column :cities_count do |region|
|
||||
region.cities.count
|
||||
end
|
||||
actions
|
||||
end
|
||||
end
|
@ -1,98 +0,0 @@
|
||||
# app/admin/sidekiq_tasks.rb
|
||||
ActiveAdmin.register_page "Sidekiq Tasks" do
|
||||
# menu label: "Sidekiq Tasks", priority: 99
|
||||
menu label: "Sidekiq Tasks Manager", parent: "系统管理"
|
||||
|
||||
content title: "Sidekiq Tasks Management" do
|
||||
div class: "sidekiq-tasks" do
|
||||
panel "Manual Task Execution" do
|
||||
div class: "task-buttons" do
|
||||
div class: "task-button" do
|
||||
h3 "Generate Weather Arts"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "GenerateWeatherArtsWorker"
|
||||
select name: "city_id" do
|
||||
City.all.map do |city|
|
||||
option city.name, value: city.id
|
||||
end
|
||||
end
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
|
||||
div class: "task-button" do
|
||||
h3 "Batch Generate Weather Arts"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "BatchGenerateWeatherArts"
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
|
||||
div class: "task-button" do
|
||||
h3 "Refresh Sitemap"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "RefreshSitemapWorker"
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
|
||||
div class: "task-button" do
|
||||
h3 "Clean Ahoy Data"
|
||||
form action: admin_sidekiq_tasks_run_task_path, method: :post do
|
||||
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
|
||||
input type: "hidden", name: "task", value: "CleanAhoyDataWorker"
|
||||
input type: "submit", value: "Run Task", class: "button"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
panel "Sidekiq Statistics" do
|
||||
stats = Sidekiq::Stats.new
|
||||
table class: "sidekiq-stats" do
|
||||
tr do
|
||||
th "Processed Jobs"
|
||||
td stats.processed
|
||||
end
|
||||
tr do
|
||||
th "Failed Jobs"
|
||||
td stats.failed
|
||||
end
|
||||
tr do
|
||||
th "Enqueued Jobs"
|
||||
td stats.enqueued
|
||||
end
|
||||
tr do
|
||||
th "Scheduled Jobs"
|
||||
td stats.scheduled_size
|
||||
end
|
||||
tr do
|
||||
th "Retry Set Size"
|
||||
td stats.retry_size
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
page_action :run_task, method: :post do
|
||||
task_name = params[:task]
|
||||
city_id = params[:city_id]
|
||||
|
||||
case task_name
|
||||
when "BatchGenerateWeatherArts"
|
||||
BatchGenerateWeatherArtsWorker.perform_async
|
||||
when "GenerateWeatherArtsWorker"
|
||||
GenerateWeatherArtWorker.perform_async(city_id)
|
||||
when "RefreshSitemapWorker"
|
||||
RefreshSitemapWorker.perform_async
|
||||
when "CleanAhoyDataWorker"
|
||||
CleanAhoyDataWorker.perform_async
|
||||
end
|
||||
|
||||
redirect_to admin_sidekiq_tasks_path, notice: "Task #{task_name} has been queued"
|
||||
end
|
||||
end
|
@ -1,84 +0,0 @@
|
||||
ActiveAdmin.register WeatherArt do
|
||||
menu label: "WeatherArt Manager", parent: "系统管理"
|
||||
controller do
|
||||
def find_resource
|
||||
scoped_collection.friendly.find(params[:id])
|
||||
end
|
||||
end
|
||||
# See permitted parameters documentation:
|
||||
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
|
||||
#
|
||||
# Uncomment all parameters which should be permitted for assignment
|
||||
#
|
||||
# permit_params :city_id, :weather_date, :description, :temperature, :feeling_temp, :humidity, :wind_scale, :wind_speed, :precipitation, :pressure, :visibility, :cloud, :prompt
|
||||
#
|
||||
# or
|
||||
#
|
||||
# permit_params do
|
||||
# permitted = [:city_id, :weather_date, :description, :temperature, :feeling_temp, :humidity, :wind_scale, :wind_speed, :precipitation, :pressure, :visibility, :cloud, :prompt]
|
||||
# permitted << :other if params[:action] == 'create' && current_user.admin?
|
||||
# permitted
|
||||
# end
|
||||
permit_params :city_id, :weather_date, :description, :temperature,
|
||||
:feeling_temp, :humidity, :wind_scale, :wind_speed,
|
||||
:precipitation, :pressure, :visibility, :cloud,
|
||||
:prompt, :image, :slug
|
||||
|
||||
remove_filter :image_attachment, :image_blob
|
||||
filter :city_id
|
||||
filter :weather_data
|
||||
|
||||
index do
|
||||
selectable_column
|
||||
id_column
|
||||
column :city
|
||||
column :weather_date
|
||||
column :description
|
||||
column :temperature
|
||||
column :image do |weather_art|
|
||||
image_tag(weather_art.image, size: "100x100") if weather_art.image.attached?
|
||||
end
|
||||
actions
|
||||
end
|
||||
|
||||
show do
|
||||
attributes_table do
|
||||
row :city
|
||||
row :weather_date
|
||||
row :description
|
||||
row :temperature
|
||||
row :feeling_temp
|
||||
row :humidity
|
||||
row :wind_scale
|
||||
row :wind_speed
|
||||
row :precipitation
|
||||
row :pressure
|
||||
row :visibility
|
||||
row :cloud
|
||||
row :prompt
|
||||
row :image do |weather_art|
|
||||
image_tag(weather_art.image) if weather_art.image.attached?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
form do |f|
|
||||
f.inputs do
|
||||
f.input :city
|
||||
f.input :weather_date
|
||||
f.input :description
|
||||
f.input :temperature
|
||||
f.input :feeling_temp
|
||||
f.input :humidity
|
||||
f.input :wind_scale
|
||||
f.input :wind_speed
|
||||
f.input :precipitation
|
||||
f.input :pressure
|
||||
f.input :visibility
|
||||
f.input :cloud
|
||||
f.input :prompt
|
||||
f.input :image, as: :file
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
end
|
3
app/assets/config/manifest.js
Normal file
3
app/assets/config/manifest.js
Normal file
@ -0,0 +1,3 @@
|
||||
//= link_tree ../images
|
||||
// = link_tree ../builds
|
||||
//= link_tree ../builds
|
BIN
app/assets/images/kitten.jpg
Normal file
BIN
app/assets/images/kitten.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 MiB |
1
app/assets/images/music.svg
Normal file
1
app/assets/images/music.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height='100px' width='100px' fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 64 64" style="enable-background:new 0 0 64 64;" xml:space="preserve"><g><path d="M15,29c-3.309,0-6,2.691-6,6c0,2.206,1.794,4,4,4s4-1.794,4-4h-2c0,1.103-0.897,2-2,2s-2-0.897-2-2c0-2.206,1.794-4,4-4 c3.309,0,6,2.691,6,6c0,4.411-3.589,8-8,8C7.486,45,3,40.514,3,35c0-4.057,1.984-7.869,5.308-10.195l10.9-7.63 C21.583,15.513,23,12.79,23,9.891C23,4.989,19.011,1,14.109,1C12.395,1,11,2.395,11,4.109v16.371l-3.839,2.687 C3.303,25.867,1,30.291,1,35c0,5.934,4.334,10.863,10,11.819V58c0,1.654-1.346,3-3,3s-3-1.346-3-3c0-0.551,0.449-1,1-1s1,0.449,1,1 v1h2v-1c0-1.654-1.346-3-3-3s-3,1.346-3,3c0,2.757,2.243,5,5,5s5-2.243,5-5V47c5.514,0,10-4.486,10-10C23,32.589,19.411,29,15,29z M13,4.109C13,3.498,13.498,3,14.109,3C17.909,3,21,6.091,21,9.891c0,2.247-1.099,4.357-2.939,5.646L13,19.079V4.109z"></path><path d="M31,45.22l10,2v-8.04l-12-2.4V49h-2c-2.206,0-4,1.794-4,4s1.794,4,4,4s4-1.794,4-4V45.22z M31,39.22l8,1.6v3.96l-8-1.6 V39.22z M29,53c0,1.103-0.897,2-2,2s-2-0.897-2-2s0.897-2,2-2h2V53z"></path><path d="M51,18.895V29h-2c-2.206,0-4,1.794-4,4s1.794,4,4,4s4-1.794,4-4v-5.895l8,0.8V31h-2c-2.206,0-4,1.794-4,4s1.794,4,4,4 s4-1.794,4-4V20.095L51,18.895z M51,33c0,1.103-0.897,2-2,2s-2-0.897-2-2s0.897-2,2-2h2V33z M61,35c0,1.103-0.897,2-2,2 s-2-0.897-2-2s0.897-2,2-2h2V35z M53,25.095v-3.99l8,0.8v3.99L53,25.095z"></path><path d="M43,9h2v12h2V5c0-2.206-1.794-4-4-4s-4,1.794-4,4S40.794,9,43,9z M43,3c1.103,0,2,0.897,2,2v2h-2c-1.103,0-2-0.897-2-2 S41.897,3,43,3z"></path><path d="M29,23c2.206,0,4-1.794,4-4V3h-2v12h-2c-2.206,0-4,1.794-4,4S26.794,23,29,23z M29,17h2v2c0,1.103-0.897,2-2,2 s-2-0.897-2-2S27.897,17,29,17z"></path><path d="M36.792,61.086l0.813,1.828c8.887-3.949,15.894-11.447,19.224-20.572l-1.879-0.686 C51.804,50.275,45.186,57.357,36.792,61.086z"></path><path d="M56.434,16.628c-1.505-3.76-3.624-7.221-6.298-10.286l-1.507,1.314c2.526,2.896,4.527,6.165,5.949,9.715L56.434,16.628z"></path><path d="M53.532,39.309l-1.902-0.617c-3.047,9.39-11.047,16.797-20.879,19.332l0.499,1.937 C41.737,57.256,50.275,49.343,53.532,39.309z"></path><path d="M51.841,16.554c-0.713-1.432-1.547-2.823-2.481-4.134l-1.629,1.16c0.873,1.226,1.653,2.526,2.32,3.866L51.841,16.554z"></path><path d="M34.081,34.615l1.846,0.771C36.639,33.677,37,31.866,37,30c0-2.683-0.761-5.29-2.201-7.539l-1.685,1.078 C34.348,25.466,35,27.7,35,30C35,31.6,34.69,33.152,34.081,34.615z"></path><path d="M41,30c0,2.291-0.424,4.522-1.261,6.631l1.859,0.737C42.528,35.023,43,32.544,43,30c0-6.212-2.815-11.967-7.723-15.79 l-1.229,1.578C38.466,19.229,41,24.409,41,30z"></path></g></svg>
|
After Width: | Height: | Size: 2.7 KiB |
35
app/assets/images/rails.svg
Normal file
35
app/assets/images/rails.svg
Normal file
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 400 140" enable-background="new 0 0 400 140" xml:space="preserve">
|
||||
<style>.a{fill:#c00;}</style>
|
||||
<title>rails-logo</title>
|
||||
<g>
|
||||
<path class="a" d="M346.6,121.5v18.1c0,0,23.4,0,32.7,0c6.7,0,18.2-4.9,18.6-18.6c0-0.6,0-6.5,0-7c0-11.7-9.6-18.6-18.6-18.6
|
||||
c-4.2,0-16.3,0-16.3,0V87l32.3,0V68.8c0,0-22.2,0-31,0c-8,0-18.7,6.6-18.7,18.9c0,1.2,0,5.2,0,6.3c0,12.3,10.6,18.6,18.7,18.6
|
||||
c22.5,0.1-5.4,0,15.4,0c0,8.8,0,8.8,0,8.8"/>
|
||||
<path class="a" d="M171.4,117.1c0,0,17.5-1.5,17.5-24.1s-21.2-24.7-21.2-24.7h-38.2v71.3h19.2v-17.2l16.6,17.2h28.4
|
||||
L171.4,117.1z M164,102.5h-15.3V86.2h15.4c0,0,4.3,1.6,4.3,8.1S164,102.5,164,102.5z"/>
|
||||
<path class="a" d="M236.3,68.8c-4.9,0-5.6,0-19.5,0c-13.9,0-18.6,12.6-18.6,18.6c0,13,0,52.2,0,52.2h19.5v-12.5H236v12.5h18.9
|
||||
c0,0,0-38.5,0-52.2C254.9,72.2,241.1,68.8,236.3,68.8z M236,106.9h-18.4V89.6c0,0,0-3.9,6.1-3.9c5.6,0,1,0,6.7,0
|
||||
c5.4,0,5.5,3.9,5.5,3.9V106.9z"/>
|
||||
<rect x="263.8" y="68.8" class="a" width="20.3" height="70.8"/>
|
||||
<polygon class="a" points="312.6,121.3 312.6,68.8 292.4,68.8 292.4,121.3 292.4,139.6 312.6,139.6 339.9,139.6 339.9,121.3
|
||||
"/>
|
||||
<path class="a" d="M9,139.6h79c0,0-15.1-68.9,34.9-96.8c10.9-5.3,45.6-25.1,102.4,16.9c1.8-1.5,3.5-2.7,3.5-2.7
|
||||
S176.8,5.1,118.9,10.9C89.8,13.5,54,40,33,75S9,139.6,9,139.6z"/>
|
||||
<path class="a" d="M9,139.6h79c0,0-15.1-68.9,34.9-96.8c10.9-5.3,45.6-25.1,102.4,16.9c1.8-1.5,3.5-2.7,3.5-2.7
|
||||
S176.8,5.1,118.9,10.9C89.8,13.5,54,40,33,75S9,139.6,9,139.6z"/>
|
||||
<path class="a" d="M9,139.6h79c0,0-15.1-68.9,34.9-96.8c10.9-5.3,45.6-25.1,102.4,16.9c1.8-1.5,3.5-2.7,3.5-2.7
|
||||
S176.8,5.1,118.9,10.9C89.7,13.5,53.9,40,32.9,75S9,139.6,9,139.6z"/>
|
||||
<path class="a" d="M173.6,16.5l0.4-6.7c-0.9-0.5-3.4-1.7-9.7-3.5l-0.4,6.6C167.2,14,170.4,15.2,173.6,16.5z"/>
|
||||
<path class="a" d="M164.1,37.7l-0.4,6.3c3.3,0.1,6.6,0.5,9.9,1.2l0.4-6.2C170.6,38.3,167.3,37.9,164.1,37.7z"/>
|
||||
<path class="a" d="M127.1,6.5c0.3,0,0.7,0,1,0l-2-6.1c-3.1,0-6.3,0.2-9.6,0.6l1.9,5.9C121.3,6.6,124.2,6.5,127.1,6.5z"/>
|
||||
<path class="a" d="M131.9,43.3l2.3,6.9c2.9-1.4,5.8-2.6,8.7-3.5l-2.2-6.6C137.3,41.1,134.4,42.2,131.9,43.3z"/>
|
||||
<path class="a" d="M86.5,17L82,10.1c-2.5,1.3-5.1,2.7-7.8,4.3l4.6,7C81.4,19.8,83.9,18.3,86.5,17z"/>
|
||||
<path class="a" d="M107,62l4.8,7.2c1.7-2.5,3.7-4.8,5.9-7.1l-4.5-6.8C110.9,57.4,108.8,59.7,107,62z"/>
|
||||
<path class="a" d="M92.5,94.2l8.1,6.4c0.4-3.9,1.1-7.8,2.1-11.7l-7.2-5.7C94.2,86.9,93.3,90.6,92.5,94.2z"/>
|
||||
<path class="a" d="M48.7,46.7l-7.1-6.2c-2.6,2.5-5.1,5-7.4,7.5l7.7,6.6C44,51.9,46.3,49.2,48.7,46.7z"/>
|
||||
<path class="a" d="M18.5,91.4L7,87.2c-1.9,4.3-4,9.3-5,12l11.5,4.2C14.8,100,16.9,95.1,18.5,91.4z"/>
|
||||
<path class="a" d="M91,119.6c0.2,5.3,0.7,9.6,1.2,12.6l12,4.3c-0.9-3.9-1.8-8.3-2.4-13L91,119.6z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
Before Width: | Height: | Size: 5.9 KiB |
@ -1,20 +0,0 @@
|
||||
// Sass variable overrides must be declared before loading up Active Admin's styles.
|
||||
//
|
||||
// To view the variables that Active Admin provides, take a look at
|
||||
// `app/assets/stylesheets/active_admin/mixins/_variables.scss` in the
|
||||
// Active Admin source.
|
||||
//
|
||||
// For example, to change the sidebar width:
|
||||
// $sidebar-width: 242px;
|
||||
|
||||
// Active Admin's got SASS!
|
||||
//@import "active_admin/mixins";
|
||||
//@import "active_admin/base";
|
||||
@import "@activeadmin/activeadmin/src/scss/mixins";
|
||||
@import "@activeadmin/activeadmin/src/scss/base";
|
||||
|
||||
|
||||
// Overriding any non-variable Sass must be done after the fact.
|
||||
// For example, to change the default status-tag color:
|
||||
//
|
||||
// .status_tag { background: #6090DB; }
|
@ -1 +0,0 @@
|
||||
// Entry point for your Sass build
|
@ -1,6 +1,31 @@
|
||||
@import "photoswipe/dist/photoswipe.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply py-2 px-4 bg-blue-200;
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply leading-none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-[3em] tracking-[-2px] mb-8 mt-8 text-center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-[1.2em] tracking-[-1px] mb-[30px] text-center font-normal text-gray-400;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-[1.1em] leading-[1.7];
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
# 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
|
15
app/controllers/account_activations_controller.rb
Normal file
15
app/controllers/account_activations_controller.rb
Normal file
@ -0,0 +1,15 @@
|
||||
class AccountActivationsController < ApplicationController
|
||||
include SessionsHelper
|
||||
def edit
|
||||
user = User.find_by(email: params[:email])
|
||||
if user && !user.activated? && user.authenticated?(:activation, params[:id])
|
||||
user.activate
|
||||
log_in user
|
||||
flash[:success] = "Account activated!"
|
||||
redirect_to user
|
||||
else
|
||||
flash[:danger] = "Invalid activation link"
|
||||
redirect_to root_url
|
||||
end
|
||||
end
|
||||
end
|
@ -1,74 +1,8 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include SeoConcern
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
before_action :log_browser_info
|
||||
# allow_browser versions: :modern
|
||||
# allow_browser versions: :modern,
|
||||
# patterns: [
|
||||
# # 鸿蒙系统相关
|
||||
# /OpenHarmony/, # 鸿蒙系统标识
|
||||
# /ArkWeb\/[\d.]+/, # 鸿蒙浏览器内核
|
||||
# /Mobile HuaweiBrowser/, # 华为浏览器(新格式)
|
||||
# /HuaweiBrowser\/[\d.]+/, # 华为浏览器(旧格式)
|
||||
#
|
||||
# # 夸克浏览器(更宽松的匹配)
|
||||
# /Quark[\s\/][\d.]+/, # 匹配 "Quark/7.4.6.681" 或 "Quark 7.4.6.681"
|
||||
#
|
||||
# /Mobile Safari/,
|
||||
# /Chrome\/[\d.]+/,
|
||||
# /Quark\/[\d.]+/,
|
||||
# /HuaweiBrowser\/[\d.]+/,
|
||||
# /MiuiBrowser\/[\d.]+/,
|
||||
# /VivoBrowser\/[\d.]+/,
|
||||
# /OppoBrowser\/[\d.]+/,
|
||||
# /UCBrowser\/[\d.]+/,
|
||||
# /QQBrowser\/[\d.]+/,
|
||||
# /MicroMessenger\/[\d.]+/,
|
||||
# /Alipay/,
|
||||
# /BaiduBoxApp/,
|
||||
# /baiduboxapp/i,
|
||||
# /SogouMobile/,
|
||||
# /Weibo/,
|
||||
# /DingTalk/,
|
||||
# /ToutiaoMicroApp/,
|
||||
# /BytedanceWebview/,
|
||||
# /ArkWeb/
|
||||
# ],
|
||||
# on_failure: ->(browser) {
|
||||
# Rails.logger.warn <<~BROWSER_INFO
|
||||
# Browser Blocked:
|
||||
# User Agent: #{browser.ua}
|
||||
# Name: #{browser.name}
|
||||
# Version: #{browser.version}
|
||||
# Platform: #{browser.platform.name}
|
||||
# Device: #{browser.device.name}
|
||||
# Mobile: #{browser.mobile?}
|
||||
# Modern: #{browser.modern?}
|
||||
# Bot: #{browser.bot?}
|
||||
# BROWSER_INFO
|
||||
# }
|
||||
before_action :set_locale
|
||||
after_action :track_action
|
||||
allow_browser versions: :modern
|
||||
|
||||
def log_browser_info
|
||||
# 构建详细的浏览器信息
|
||||
Rails.logger.debug "User Agent: #{request.user_agent}"
|
||||
|
||||
# 如果是被拦截的浏览器,记录额外信息
|
||||
# unless browser_allowed?
|
||||
# Rails.logger.info "User Agent: #{request.user_agent}"
|
||||
# end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def track_action
|
||||
ahoy.track "Viewed Application", request.path_parameters
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_locale
|
||||
I18n.locale = params[:locale] || I18n.default_locale
|
||||
def hello
|
||||
render html: "Hello World!"
|
||||
end
|
||||
end
|
||||
|
@ -1,21 +0,0 @@
|
||||
class ArtsController < ApplicationController
|
||||
def index
|
||||
@regions = Region.all
|
||||
@current_region = Region.find(params[:region]) if params[:region].present?
|
||||
|
||||
@weather_arts = WeatherArt.includes(city: [ :country, { country: :region } ])
|
||||
|
||||
if @current_region
|
||||
@weather_arts = @weather_arts.joins(city: :country)
|
||||
.where(countries: { region_id: @current_region.id })
|
||||
end
|
||||
|
||||
@weather_arts = if params[:sort] == "oldest"
|
||||
@weather_arts.order(created_at: :asc)
|
||||
else
|
||||
@weather_arts.order(created_at: :desc)
|
||||
end
|
||||
|
||||
@weather_arts = @weather_arts.page(params[:page]).per(10)
|
||||
end
|
||||
end
|
@ -1,42 +1,11 @@
|
||||
class CitiesController < ApplicationController
|
||||
def index
|
||||
@regions = Region.includes(:countries).order(:name)
|
||||
@cities = City.includes(:country, country: :region).order(:name)
|
||||
|
||||
if params[:region]
|
||||
@current_region = Region.friendly.find(params[:region])
|
||||
@cities = @cities.by_region(@current_region.id)
|
||||
end
|
||||
|
||||
if params[:country]
|
||||
@current_country = Country.friendly.find(params[:country])
|
||||
@cities = @cities.by_country(@current_country.id)
|
||||
end
|
||||
|
||||
@cities = @cities.page(params[:page]).per(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"
|
||||
)
|
||||
@cities = City.all
|
||||
@cities = City.all.includes(:weather_arts).page(params[:page])
|
||||
end
|
||||
|
||||
def show
|
||||
@city = City.friendly.find(params[:id])
|
||||
ahoy.track "View City", {
|
||||
city_id: @city.id,
|
||||
name: @city.name,
|
||||
event_type: "city_view"
|
||||
}
|
||||
|
||||
set_meta_tags(
|
||||
title: @city.name,
|
||||
description: "Experience #{@city.name}'s weather through AI-generated art. Daily updates of weather conditions visualized through artificial intelligence.",
|
||||
keywords: "#{@city.name}, #{@city.country.name}, weather art, AI visualization",
|
||||
og: {
|
||||
image: @city.latest_weather_art&.image&.attached? ? url_for(@city.latest_weather_art.image) : nil
|
||||
}
|
||||
)
|
||||
@city = City.find(params[:id])
|
||||
@weather_arts = @city.weather_arts.latest.page(params[:page])
|
||||
end
|
||||
end
|
||||
|
@ -1,11 +0,0 @@
|
||||
class HomeController < ApplicationController
|
||||
def index
|
||||
@latest_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(20).shuffle.last(10)
|
||||
@featured_arts = WeatherArt.includes(:city).order(created_at: :desc).limit(5)
|
||||
set_meta_tags(
|
||||
title: "AI-Generated Weather Art",
|
||||
description: "Experience weather through artistic AI visualization. Daily updated weather art for cities worldwide.",
|
||||
keywords: "AI weather art, weather visualization, city weather, artificial intelligence"
|
||||
)
|
||||
end
|
||||
end
|
65
app/controllers/password_resets_controller.rb
Normal file
65
app/controllers/password_resets_controller.rb
Normal file
@ -0,0 +1,65 @@
|
||||
class PasswordResetsController < ApplicationController
|
||||
before_action :get_user, only: [ :edit, :update ]
|
||||
before_action :valid_user, only: [ :edit, :update ]
|
||||
before_action :check_expiration, only: [ :edit, :update ]
|
||||
|
||||
include SessionsHelper
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.find_by(email: params[:password_reset][:email].downcase)
|
||||
if @user
|
||||
@user.create_reset_digest
|
||||
@user.send_password_reset_email
|
||||
flash[:info] = "Email send with password reset instructions"
|
||||
redirect_to root_url
|
||||
else
|
||||
flash.now[:danger] = "Email not found"
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if params[:user][:password].empty?
|
||||
@user.errors.add(:password, "can't be empty")
|
||||
render "edit", status: :unprocessable_entity
|
||||
elsif @user.update(user_params)
|
||||
forget(@user)
|
||||
reset_session
|
||||
@user.update_attribute(:reset_digest, nil)
|
||||
log_in @user
|
||||
flash[:success] = "Password has been reset"
|
||||
redirect_to @user
|
||||
else
|
||||
render "edit", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:password, :password_confirmation)
|
||||
end
|
||||
|
||||
def get_user
|
||||
@user = User.find_by(email: params[:email])
|
||||
end
|
||||
|
||||
def valid_user
|
||||
unless @user && @user.activated? &&
|
||||
@user.authenticated?(:reset, params[:id])
|
||||
redirect_to root_url
|
||||
end
|
||||
end
|
||||
|
||||
def check_expiration
|
||||
if @user.password_reset_expired?
|
||||
flash[:danger] = "Password reset has expired"
|
||||
redirect_to new_password_reset_url
|
||||
end
|
||||
end
|
||||
end
|
32
app/controllers/sessions_controller.rb
Normal file
32
app/controllers/sessions_controller.rb
Normal file
@ -0,0 +1,32 @@
|
||||
class SessionsController < ApplicationController
|
||||
include SessionsHelper
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:session][:email].downcase)
|
||||
# if user && user.authenticate(params[:session][:password])
|
||||
if user&.authenticate(params[:session][:password])
|
||||
if user.activated?
|
||||
forwarding_url = session[:forwarding_url]
|
||||
reset_session
|
||||
params[:session][:remember_me] == "1" ? remember(user) : forget(user)
|
||||
log_in user
|
||||
redirect_to forwarding_url || user
|
||||
else
|
||||
message = "Account not activated. "
|
||||
message += "Check your email for the activation link."
|
||||
flash[:danger] = message
|
||||
redirect_to root_url
|
||||
end
|
||||
else
|
||||
flash.now[:danger] = "Invalid email/password combination"
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
log_out if logged_in?
|
||||
redirect_to root_url
|
||||
end
|
||||
end
|
@ -1,33 +0,0 @@
|
||||
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
|
18
app/controllers/static_pages_controller.rb
Normal file
18
app/controllers/static_pages_controller.rb
Normal file
@ -0,0 +1,18 @@
|
||||
class StaticPagesController < ApplicationController
|
||||
def home
|
||||
@featured_cities = City.featured.limit(5)
|
||||
@latest_arts = WeatherArt.latest.limit(6)
|
||||
end
|
||||
|
||||
def help
|
||||
end
|
||||
|
||||
def about
|
||||
end
|
||||
|
||||
def contact
|
||||
end
|
||||
|
||||
def demo
|
||||
end
|
||||
end
|
90
app/controllers/users_controller.rb
Normal file
90
app/controllers/users_controller.rb
Normal file
@ -0,0 +1,90 @@
|
||||
class UsersController < ApplicationController
|
||||
include SessionsHelper
|
||||
before_action :logged_in_user, only: [ :index, :edit, :update, :destroy ]
|
||||
before_action :correct_user, only: [ :edit, :update ]
|
||||
before_action :admin_user, only: [ :destroy ]
|
||||
|
||||
def index
|
||||
# @users = User.all
|
||||
# @users = User.order(:name).page(params[:page])
|
||||
@users = User.where(activated: true).page(params[:page])
|
||||
end
|
||||
|
||||
def show
|
||||
@user = User.find(params[:id])
|
||||
redirect_to root_url and return unless @user.activated?
|
||||
# debugger
|
||||
end
|
||||
def new
|
||||
@user = User.new
|
||||
# debugger
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
if @user.save
|
||||
# reset_session
|
||||
# log_in @user
|
||||
# flash[:success] = "Welcome to the Sample App!"
|
||||
# redirect_to @user
|
||||
# redirect_to user_url(@user)
|
||||
begin
|
||||
@user.send_activation_email
|
||||
flash[:info] = "Please check your email to activate your account."
|
||||
redirect_to root_url
|
||||
rescue => e
|
||||
logger.error "User creation failed: #{e.message}"
|
||||
flash[:danger] = "Something went wrong. Please try again."
|
||||
@user.destroy
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@user = User.find(params[:id])
|
||||
end
|
||||
|
||||
def update
|
||||
@user = User.find(params[:id])
|
||||
if @user.update(user_params)
|
||||
flash[:success] = "Profile updated"
|
||||
redirect_to @user
|
||||
# redirect_to user_url(@user)
|
||||
else
|
||||
render "edit"
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
User.find(params[:id]).destroy
|
||||
flash[:success] = "User deleted"
|
||||
redirect_to users_url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password,
|
||||
:password_confirmation)
|
||||
end
|
||||
|
||||
def logged_in_user
|
||||
unless logged_in?
|
||||
store_location
|
||||
flash[:danger] = "Please log in."
|
||||
redirect_to login_url
|
||||
end
|
||||
end
|
||||
|
||||
def correct_user
|
||||
@user = User.find(params[:id])
|
||||
redirect_to(root_url) unless current_user?(@user)
|
||||
end
|
||||
|
||||
def admin_user
|
||||
redirect_to(root_url) unless current_user.admin?
|
||||
end
|
||||
end
|
@ -1,36 +0,0 @@
|
||||
class WeatherArtsController < ApplicationController
|
||||
def show
|
||||
@city = City.friendly.find(params[:city_id])
|
||||
@weather_art = @city.weather_arts.friendly.find(params[:slug])
|
||||
|
||||
@previous_weather_art = @city.weather_arts
|
||||
.where("id < ?", @weather_art.id)
|
||||
.order(id: :desc)
|
||||
.first
|
||||
|
||||
@next_weather_art = @city.weather_arts
|
||||
.where("id > ?", @weather_art.id)
|
||||
.order(id: :asc)
|
||||
.first
|
||||
|
||||
ahoy.track "View Weather Art", {
|
||||
weather_art_id: @weather_art.id,
|
||||
city_id: @weather_art.city_id,
|
||||
event_type: "weather_art_view"
|
||||
}
|
||||
ahoy.track "View City", {
|
||||
city_id: @city.id,
|
||||
name: @city.name,
|
||||
event_type: "city_view"
|
||||
}
|
||||
|
||||
set_meta_tags(
|
||||
title: "#{@city.name} Weather Art - #{@weather_art.weather_date.strftime('%B %d, %Y')}",
|
||||
description: "#{@city.name}'s weather visualized through AI art. #{@weather_art.description} at #{@weather_art.temperature}°C.",
|
||||
keywords: "#{@city.name}, weather art, #{@weather_art.description}, AI visualization",
|
||||
og: {
|
||||
image: @weather_art.image.attached? ? url_for(@weather_art.image) : nil
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
2
app/helpers/account_activations_helper.rb
Normal file
2
app/helpers/account_activations_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module AccountActivationsHelper
|
||||
end
|
@ -1,24 +1,10 @@
|
||||
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?
|
||||
def full_title(page_title = "")
|
||||
base_title = "Ruby on Rails Tutorial Sample App"
|
||||
if page_title.empty?
|
||||
base_title
|
||||
else
|
||||
page_title + " | " + base_title
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -1,2 +0,0 @@
|
||||
module ArtsHelper
|
||||
end
|
@ -1,2 +0,0 @@
|
||||
module HomeHelper
|
||||
end
|
2
app/helpers/password_resets_helper.rb
Normal file
2
app/helpers/password_resets_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module PasswordResetsHelper
|
||||
end
|
53
app/helpers/sessions_helper.rb
Normal file
53
app/helpers/sessions_helper.rb
Normal file
@ -0,0 +1,53 @@
|
||||
module SessionsHelper
|
||||
def log_in(user)
|
||||
session[:user_id] = user.id
|
||||
# 防范会话重放攻击
|
||||
session[:session_token] = user.session_token
|
||||
end
|
||||
|
||||
def remember(user)
|
||||
user.remember
|
||||
cookies.permanent.encrypted[:user_id] = user.id
|
||||
cookies.permanent[:remember_token] = user.remember_token
|
||||
end
|
||||
|
||||
def current_user
|
||||
if (user_id = session[:user_id])
|
||||
user = User.find_by(id: user_id)
|
||||
if user && session[:session_token] == user.session_token
|
||||
@current_user = user
|
||||
end
|
||||
elsif (user_id = cookies.encrypted[:user_id])
|
||||
user = User.find_by(id: user_id)
|
||||
if user && user.authenticated?(:remember, cookies[:remember_token])
|
||||
log_in user
|
||||
@current_user = user
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
!current_user.nil?
|
||||
end
|
||||
|
||||
def forget(user)
|
||||
user.forget
|
||||
cookies.delete(:user_id)
|
||||
cookies.delete(:remember_token)
|
||||
end
|
||||
|
||||
def log_out
|
||||
forget(current_user)
|
||||
reset_session
|
||||
@current_user = nil
|
||||
end
|
||||
|
||||
def current_user?(user)
|
||||
user && user == current_user
|
||||
end
|
||||
|
||||
def store_location
|
||||
session[:forwarding_url] = request.original_url if
|
||||
request.get? || request.head?
|
||||
end
|
||||
end
|
@ -1,2 +0,0 @@
|
||||
module SitemapsHelper
|
||||
end
|
2
app/helpers/static_pages_helper.rb
Normal file
2
app/helpers/static_pages_helper.rb
Normal file
@ -0,0 +1,2 @@
|
||||
module StaticPagesHelper
|
||||
end
|
8
app/helpers/users_helper.rb
Normal file
8
app/helpers/users_helper.rb
Normal file
@ -0,0 +1,8 @@
|
||||
module UsersHelper
|
||||
def gravatar_for(user, options = { size: 80 })
|
||||
size = options[:size]
|
||||
gravatar_id = Digest::MD5.hexdigest(user.email.downcase)
|
||||
gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
|
||||
image_tag(gravatar_url, alt: user.name, class: "gravatar")
|
||||
end
|
||||
end
|
@ -1,51 +0,0 @@
|
||||
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
|
@ -1,6 +0,0 @@
|
||||
|
||||
import './add_jquery'
|
||||
import "jquery/dist/jquery"
|
||||
import "jquery-ui/dist/jquery-ui"
|
||||
import "jquery-ujs"
|
||||
import "@activeadmin/activeadmin"
|
@ -1,4 +0,0 @@
|
||||
import jquery from 'jquery'
|
||||
import $ from 'jquery'
|
||||
window.jQuery = jquery
|
||||
window.$ = $
|
@ -1,10 +1,4 @@
|
||||
// Entry point for the build script in your package.json
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails"
|
||||
import "@hotwired/stimulus"
|
||||
import "@fontsource/playfair-display/400.css";
|
||||
import "@fontsource/playfair-display/700.css";
|
||||
import "@fontsource/raleway/400.css";
|
||||
import "@fontsource/raleway/600.css";
|
||||
|
||||
import "./controllers"
|
||||
import "./active_admin"
|
||||
|
@ -1,14 +1,13 @@
|
||||
// This file is auto-generated by ./bin/rails stimulus:manifest:update
|
||||
// Run that command whenever you add a new controller or create them with
|
||||
// ./bin/rails generate stimulus controllerName
|
||||
// Import and register all your controllers from the importmap via controllers/**/*_controller
|
||||
// import { application } from "controllers/application"
|
||||
// import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
// eagerLoadControllersFrom("controllers", application)
|
||||
|
||||
import { application } from "./application"
|
||||
import ThemeController from "./theme_controller"
|
||||
import ToastController from "./toast_controller"
|
||||
|
||||
import HelloController from "./hello_controller"
|
||||
application.register("hello", HelloController)
|
||||
|
||||
import PhotoSwipeLightBoxController from "./photo_swipe_lightbox_controller"
|
||||
|
||||
console.log("ready to register photo-swipe")
|
||||
application.register("photo-swipe-lightbox", PhotoSwipeLightBoxController)
|
||||
console.log("successful to register photo-swipe")
|
||||
application.register("theme", ThemeController)
|
||||
application.register("toast", ToastController)
|
||||
|
@ -1,42 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import PhotoSwipeLightbox from 'photoswipe/lightbox'
|
||||
import PhotoSwipe from 'photoswipe'
|
||||
import 'photoswipe/dist/photoswipe.css'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['image', 'gallery']
|
||||
|
||||
connect() {
|
||||
this.initPhotoSwipeLightbox()
|
||||
}
|
||||
|
||||
initPhotoSwipeLightbox() {
|
||||
const lightbox = new PhotoSwipeLightbox({
|
||||
gallery: this.galleryTarget,
|
||||
children: 'a',
|
||||
pswpModule: PhotoSwipe,
|
||||
initialZoomInEndEvent: 'mousedown',
|
||||
dataSource: (items) => {
|
||||
return items.map((item) => ({
|
||||
src: item.dataset.pswpSrc,
|
||||
w: parseInt(item.dataset.pswpWidth, 10),
|
||||
h: parseInt(item.dataset.pswpHeight, 10),
|
||||
title: item.dataset.pswpCaption,
|
||||
}))
|
||||
},
|
||||
padding: { top: 0, bottom: 0, left: 0, right: 0 }, // 自定义图片与页面边界的填充
|
||||
closeOnScroll: false,
|
||||
zoom: true, // 启用缩放功能
|
||||
bgOpacity: 0.9, // 背景透明度
|
||||
pswpUIOptions: {
|
||||
arrowPrev: true,
|
||||
arrowNext: true,
|
||||
zoom: true, // 添加缩放按钮
|
||||
fullscreen: true, // 添加全屏按钮
|
||||
counter: true, // 显示当前图片编号
|
||||
}
|
||||
})
|
||||
lightbox.init()
|
||||
// console.log('PhotoSwipeLightbox instance:', lightbox);
|
||||
}
|
||||
}
|
24
app/javascript/controllers/theme_controller.js
Normal file
24
app/javascript/controllers/theme_controller.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="theme"
|
||||
export default class extends Controller {
|
||||
static targets = ["toggle"]
|
||||
|
||||
connect() {
|
||||
this.initTheme()
|
||||
}
|
||||
|
||||
initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if(savedTheme) {
|
||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||
this.toggleTarget.checked = savedTheme === 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
const newTheme = event.target.checked ? 'dark' : 'light'
|
||||
document.documentElement.setAttribute('data-theme', newTheme)
|
||||
localStorage.setItem('theme', newTheme)
|
||||
}
|
||||
}
|
14
app/javascript/controllers/toast_controller.js
Normal file
14
app/javascript/controllers/toast_controller.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="toast"
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
// 3秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
this.element.classList.add('animate-fade-out')
|
||||
setTimeout(() => {
|
||||
this.element.remove()
|
||||
}, 500)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
22
app/javascript/theme_switch.js
Normal file
22
app/javascript/theme_switch.js
Normal file
@ -0,0 +1,22 @@
|
||||
// 获取切换按钮
|
||||
const themeToggle = document.querySelector('.theme-controller');
|
||||
|
||||
// 监听变化
|
||||
themeToggle.addEventListener('change', (e) => {
|
||||
// 切换 HTML data-theme 属性
|
||||
if(e.target.checked) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
|
||||
// 保存主题设置到 localStorage
|
||||
localStorage.setItem('theme', e.target.checked ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
// 页面加载时检查之前的主题设置
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if(savedTheme) {
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
themeToggle.checked = savedTheme === 'dark';
|
||||
}
|
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal file
@ -0,0 +1,7 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
default from: ENV.fetch("RAILS_SMTP_USERNAME", "noreply@mail.frytea.com")
|
||||
layout "mailer"
|
||||
end
|
||||
|
21
app/mailers/user_mailer.rb
Normal file
21
app/mailers/user_mailer.rb
Normal file
@ -0,0 +1,21 @@
|
||||
class UserMailer < ApplicationMailer
|
||||
# Subject can be set in your I18n file at config/locales/en.yml
|
||||
# with the following lookup:
|
||||
#
|
||||
# en.user_mailer.account_activation.subject
|
||||
#
|
||||
def account_activation(user)
|
||||
@user = user
|
||||
mail to: user.email, subject: "Account activation"
|
||||
end
|
||||
|
||||
# Subject can be set in your I18n file at config/locales/en.yml
|
||||
# with the following lookup:
|
||||
#
|
||||
# en.user_mailer.password_reset.subject
|
||||
#
|
||||
def password_reset(user)
|
||||
@user = user
|
||||
mail to: user.email, subject: "Password reset"
|
||||
end
|
||||
end
|
@ -1,11 +0,0 @@
|
||||
class AdminUser < ApplicationRecord
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
|
||||
devise :database_authenticatable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
# 列出你想要允许搜索的属性
|
||||
%w[created_at email id updated_at]
|
||||
end
|
||||
end
|
@ -1,14 +0,0 @@
|
||||
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
|
@ -1,10 +0,0 @@
|
||||
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
|
@ -1,131 +1,13 @@
|
||||
class City < ApplicationRecord
|
||||
extend FriendlyId
|
||||
friendly_id :slug_candidates, use: :slugged
|
||||
belongs_to :country
|
||||
has_many :weather_arts
|
||||
|
||||
has_many :weather_arts, dependent: :destroy
|
||||
scope :featured, -> { where(featured: true) }
|
||||
|
||||
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :city_id
|
||||
has_many :events, class_name: "Ahoy::Event", foreign_key: :city_id
|
||||
|
||||
delegate :region, to: :country
|
||||
|
||||
validates :name, presence: true
|
||||
validates :latitude, presence: true
|
||||
validates :longitude, presence: true
|
||||
|
||||
delegate :region, to: :country
|
||||
|
||||
scope :by_region, ->(region_id) { joins(:country).where(countries: { region_id: region_id }) }
|
||||
scope :by_country, ->(country_id) { where(country_id: country_id) }
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
scope :by_popularity, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.city_id') = cities.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
else
|
||||
joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'city_id')::integer = cities.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
end
|
||||
}
|
||||
|
||||
scope :least_popular_active, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
active
|
||||
.joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.city_id') = cities.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count ASC, cities.name ASC")
|
||||
else
|
||||
active
|
||||
.joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'city_id')::integer = cities.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count ASC, cities.name ASC")
|
||||
end
|
||||
}
|
||||
scope :most_popular_inactive, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
where(active: false)
|
||||
.joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.city_id') = cities.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("COUNT(ahoy_events.id) DESC, cities.name ASC")
|
||||
else
|
||||
where(active: false)
|
||||
.joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'city_id')::integer = cities.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'city_view'")
|
||||
.group("cities.id")
|
||||
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("COUNT(ahoy_events.id) DESC, cities.name ASC")
|
||||
end
|
||||
}
|
||||
|
||||
|
||||
def to_s
|
||||
name
|
||||
def current_weather_art
|
||||
weather_arts.find_by(weather_date: Date.current)
|
||||
end
|
||||
|
||||
def slug_candidates
|
||||
[
|
||||
:name,
|
||||
[ :country, :name ]
|
||||
]
|
||||
end
|
||||
|
||||
def localized_name
|
||||
I18n.t("cities.#{name.parameterize.underscore}", default: name)
|
||||
end
|
||||
|
||||
def full_name
|
||||
"#{name}, #{country}"
|
||||
end
|
||||
|
||||
def should_generate_new_friendly_id?
|
||||
name_changed? || super
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[ "weather_arts" ]
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "active", "country_id", "created_at", "id", "id_value", "last_image_generation", "last_weather_fetch", "latitude", "longitude", "name", "priority", "region", "slug", "timezone", "updated_at" ]
|
||||
end
|
||||
|
||||
def last_weather_fetch
|
||||
# latest_weather_art&.created_at
|
||||
Rails.cache.fetch("city/#{id}/last_weather_fetch", expires_in: 1.hour) do
|
||||
latest_weather_art&.created_at
|
||||
end
|
||||
end
|
||||
|
||||
def last_image_generation
|
||||
# latest_weather_art&.image&.created_at
|
||||
Rails.cache.fetch("city/#{id}/last_image_generation", expires_in: 1.hour) do
|
||||
latest_weather_art&.image&.created_at
|
||||
end
|
||||
end
|
||||
|
||||
def latest_weather_art
|
||||
weather_arts.order(weather_date: :desc).first
|
||||
end
|
||||
|
||||
def view_count
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
Ahoy::Event.where("json_extract(properties, '$.event_type') = 'city_view' AND json_extract(properties, '$.city_id') = ?", self.id).count
|
||||
else
|
||||
Ahoy::Event.where("properties::jsonb->>'event_type' = 'city_view' AND (properties::jsonb->>'city_id')::integer = ?", self.id).count
|
||||
end
|
||||
def weather_art_for_date(date)
|
||||
weather_arts.find_by(weather_date: date)
|
||||
end
|
||||
end
|
||||
|
@ -1,26 +0,0 @@
|
||||
class Country < ApplicationRecord
|
||||
extend FriendlyId
|
||||
friendly_id :name, use: :slugged
|
||||
|
||||
belongs_to :region
|
||||
has_many :cities, dependent: :restrict_with_error
|
||||
|
||||
validates :name, presence: true
|
||||
validates :code, presence: true, uniqueness: true
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
def localized_name
|
||||
I18n.t("countries.#{code}")
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "code", "created_at", "id", "id_value", "name", "region_id", "slug", "updated_at" ]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[ "cities", "region" ]
|
||||
end
|
||||
end
|
@ -1,28 +0,0 @@
|
||||
class Region < ApplicationRecord
|
||||
extend FriendlyId
|
||||
friendly_id :name, use: :slugged
|
||||
|
||||
has_many :countries, dependent: :restrict_with_error
|
||||
has_many :cities, through: :countries
|
||||
|
||||
validates :name, presence: true
|
||||
validates :code, presence: true, uniqueness: true
|
||||
|
||||
def to_s
|
||||
name
|
||||
end
|
||||
|
||||
def localized_name
|
||||
I18n.t("regions.#{code}")
|
||||
end
|
||||
|
||||
# 模型中允许被搜索的关联
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[ "countries", "cities" ]
|
||||
end
|
||||
|
||||
# 允许被搜索的属性列表
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "code", "created_at", "id", "id_value", "name", "slug", "updated_at" ]
|
||||
end
|
||||
end
|
102
app/models/user.rb
Normal file
102
app/models/user.rb
Normal file
@ -0,0 +1,102 @@
|
||||
class User < ApplicationRecord
|
||||
attr_accessor :remember_token, :activation_token, :reset_token
|
||||
# before_save { self.email = email.downcase }
|
||||
# before_save { email.downcase! }
|
||||
before_save :downcase_email
|
||||
before_create :create_activation_digest
|
||||
validates :name, presence: true, length: { maximum: 50 }
|
||||
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
|
||||
validates :email, presence: true, length: { maximum: 255 },
|
||||
format: { with: VALID_EMAIL_REGEX },
|
||||
uniqueness: true
|
||||
has_secure_password
|
||||
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
|
||||
|
||||
def User.digest(string)
|
||||
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
|
||||
BCrypt::Engine.cost
|
||||
BCrypt::Password.create(string, cost: cost)
|
||||
end
|
||||
|
||||
def User.new_token
|
||||
SecureRandom.urlsafe_base64
|
||||
end
|
||||
|
||||
def remember
|
||||
self.remember_token = User.new_token
|
||||
update_attribute(:remember_digest, User.digest(remember_token))
|
||||
remember_digest
|
||||
end
|
||||
|
||||
# 返回一个会话令牌,防止会话劫持
|
||||
# 简单起见,直接使用记忆令牌
|
||||
def session_token
|
||||
remember_digest || remember
|
||||
end
|
||||
|
||||
class << self
|
||||
def digest(string)
|
||||
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
|
||||
BCrypt::Engine.cost
|
||||
BCrypt::Password.create(string, cost: cost)
|
||||
end
|
||||
|
||||
def new_token
|
||||
SecureRandom.urlsafe_base64
|
||||
end
|
||||
end
|
||||
|
||||
# powered by metaprogramming
|
||||
def authenticated?(attribute, token)
|
||||
digest = send("#{attribute}_digest")
|
||||
return false if digest.nil?
|
||||
BCrypt::Password.new(digest).is_password?(token)
|
||||
end
|
||||
|
||||
def forget
|
||||
update_attribute(:remember_digest, nil)
|
||||
end
|
||||
|
||||
def activate
|
||||
# update_attribute(:activated, true)
|
||||
# update_attribute(:activated_at, Time.zone.now)
|
||||
update_columns(activated: true, activated_at: Time.zone.now)
|
||||
end
|
||||
|
||||
def send_activation_email
|
||||
begin
|
||||
result = UserMailer.account_activation(self).deliver_now
|
||||
Rails.logger.info "Email sent successfully: #{result.inspect}"
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to send email: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def create_reset_digest
|
||||
self.reset_token = User.new_token
|
||||
update_columns(reset_digest: User.digest(reset_token),
|
||||
reset_send_at: Time.zone.now)
|
||||
end
|
||||
|
||||
def send_password_reset_email
|
||||
UserMailer.password_reset(self).deliver_now
|
||||
end
|
||||
|
||||
def password_reset_expired?
|
||||
reset_send_at < 2.hours.ago
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def downcase_email
|
||||
# self.email = email.downcase
|
||||
email.downcase!
|
||||
end
|
||||
|
||||
def create_activation_digest
|
||||
self.activation_token = User.new_token
|
||||
self.activation_digest = User.digest(activation_token)
|
||||
end
|
||||
end
|
@ -1,58 +1,12 @@
|
||||
class WeatherArt < ApplicationRecord
|
||||
extend FriendlyId
|
||||
friendly_id :weather_date, use: :slugged
|
||||
|
||||
belongs_to :city
|
||||
|
||||
has_one_attached :image
|
||||
has_one_attached :image_with_watermark
|
||||
|
||||
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :weather_art_id
|
||||
has_many :events, class_name: "Ahoy::Event", foreign_key: :weather_art_id
|
||||
|
||||
validates :weather_date, presence: true
|
||||
validates :city_id, presence: true
|
||||
|
||||
scope :by_popularity, -> {
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.weather_art_id') = weather_arts.id
|
||||
AND json_extract(ahoy_events.properties, '$.event_type') = 'weather_art_view'")
|
||||
.group("weather_arts.id")
|
||||
.select("weather_arts.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
else
|
||||
joins("LEFT JOIN ahoy_events ON (ahoy_events.properties::jsonb->>'weather_art_id')::integer = weather_arts.id
|
||||
AND ahoy_events.properties::jsonb->>'event_type' = 'weather_art_view'")
|
||||
.group("weather_arts.id")
|
||||
.select("weather_arts.*, COUNT(ahoy_events.id) as visit_count")
|
||||
.order("visit_count DESC")
|
||||
end
|
||||
}
|
||||
|
||||
def should_generate_new_friendly_id?
|
||||
weather_date_changed? || city_id_changed? || super
|
||||
end
|
||||
|
||||
def to_s
|
||||
"#{city.name} - #{weather_date.strftime('%Y-%m-%d')}"
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
[ "city", "image_attachment", "image_blob" ]
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
[ "city_id", "cloud", "created_at", "description", "feeling_temp", "humidity", "id", "id_value", "precipitation", "pressure", "prompt", "temperature", "updated_at", "visibility", "weather_date", "wind_scale", "wind_speed" ]
|
||||
end
|
||||
|
||||
def view_count
|
||||
if ActiveRecord::Base.connection.adapter_name.downcase == "sqlite"
|
||||
Ahoy::Event.where("json_extract(properties, '$.event_type') = 'weather_art_view' AND json_extract(properties, '$.weather_art_id') = ?", self.id).count
|
||||
else
|
||||
Ahoy::Event.where("properties::jsonb->>'event_type' = 'weather_art_view' AND (properties::jsonb->>'weather_art_id')::integer = ?", self.id).count
|
||||
end
|
||||
end
|
||||
scope :latest, -> { order(created_at: :desc) }
|
||||
|
||||
def image_url
|
||||
image.attached? ? image.blob : nil
|
||||
# 这里实现获取图片URL的逻辑,可以是AWS S3或其他存储服务
|
||||
Rails.application.routes.url_helpers.rails_blob_path(image, only_path: true) if image.attached?
|
||||
end
|
||||
end
|
||||
|
@ -1,64 +0,0 @@
|
||||
class AiService
|
||||
def initialize
|
||||
@client = OpenAI::Client.new(
|
||||
access_token: Rails.application.credentials.openai.token,
|
||||
uri_base: Rails.application.credentials.openai.uri,
|
||||
request_timeout: 240
|
||||
)
|
||||
end
|
||||
|
||||
def generate_prompt(city, weather_data)
|
||||
response = @client.chat(
|
||||
parameters: {
|
||||
model: "gpt-4",
|
||||
messages: [ {
|
||||
role: "system",
|
||||
content: "You are a professional artist creating prompts for DALL-E 3. Create realistic, artistic weather scenes featuring iconic landmarks."
|
||||
}, {
|
||||
role: "user",
|
||||
content: generate_prompt_request(city, weather_data)
|
||||
} ],
|
||||
temperature: 0.7,
|
||||
max_tokens: 300
|
||||
}
|
||||
)
|
||||
|
||||
response.dig("choices", 0, "message", "content")
|
||||
end
|
||||
|
||||
def generate_image(prompt)
|
||||
response = @client.images.generate(
|
||||
parameters: {
|
||||
model: "dall-e-3",
|
||||
prompt: prompt,
|
||||
size: "1792x1024",
|
||||
quality: "standard",
|
||||
n: 1
|
||||
}
|
||||
)
|
||||
|
||||
response.dig("data", 0, "url")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_prompt_request(city, weather_data)
|
||||
<<~PROMPT
|
||||
Create a DALL-E 3 prompt for a weather scene in #{city.name}, #{city.country.name}.
|
||||
|
||||
Weather conditions:
|
||||
- Temperature: #{weather_data[:temperature]}°C
|
||||
- Weather: #{weather_data[:description]}
|
||||
- Cloud cover: #{weather_data[:cloud]}%
|
||||
- Time: #{weather_data[:time]}
|
||||
|
||||
Requirements:
|
||||
- Feature iconic landmarks or architecture from #{city.name}
|
||||
- Realistic style
|
||||
- Weather conditions should be clearly visible
|
||||
- Atmospheric and artistic composition
|
||||
|
||||
Generate a detailed, creative prompt that will produce a beautiful and realistic image.
|
||||
PROMPT
|
||||
end
|
||||
end
|
@ -1,38 +0,0 @@
|
||||
# app/services/weather_service.rb
|
||||
class WeatherService
|
||||
include HTTParty
|
||||
base_uri Rails.application.credentials.dig(:qweather, :uri)
|
||||
|
||||
def initialize
|
||||
@api_key = Rails.application.credentials.qweather.token
|
||||
end
|
||||
|
||||
def get_weather(latitude, longitude)
|
||||
Rails.logger.debug "Get Weather for #{latitude},#{longitude}"
|
||||
response = self.class.get(
|
||||
"/weather/now",
|
||||
headers: {
|
||||
"X-QW-Api-Key" => "#{@api_key}"
|
||||
},
|
||||
query: {
|
||||
location: "#{longitude},#{latitude}"
|
||||
})
|
||||
|
||||
return nil unless response.success?
|
||||
|
||||
data = response["now"]
|
||||
{
|
||||
temperature: data["temp"].to_f,
|
||||
feeling_temp: data["feelsLike"].to_f,
|
||||
humidity: data["humidity"].to_f,
|
||||
wind_scale: "#{data['windScale']}级",
|
||||
wind_speed: data["windSpeed"].to_f,
|
||||
precipitation: data["precip"].to_f,
|
||||
pressure: data["pressure"].to_f,
|
||||
visibility: data["vis"].to_f,
|
||||
cloud: data["cloud"].to_f,
|
||||
description: data["text"],
|
||||
time: response["updateTime"]
|
||||
}
|
||||
end
|
||||
end
|
@ -1,172 +0,0 @@
|
||||
<!-- app/views/arts/index.html.erb -->
|
||||
<div class="min-h-screen">
|
||||
<!-- 页面标题和背景 -->
|
||||
<% featured_art = @weather_arts.first %>
|
||||
<div class="relative">
|
||||
<!-- 背景图像 -->
|
||||
<% if featured_art&.image&.attached? %>
|
||||
<div class="absolute inset-0 h-[40vh] overflow-hidden">
|
||||
<%= image_tag featured_art.image,
|
||||
class: "w-full h-full object-cover" %>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-base-100/30 via-base-100/60 to-base-100"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- 标题内容 -->
|
||||
<div class="relative pt-20 pb-32">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-3xl mx-auto text-center space-y-6">
|
||||
<h1 class="text-4xl md:text-5xl font-display font-bold">
|
||||
Weather Arts Gallery
|
||||
</h1>
|
||||
<p class="text-xl text-base-content/70">
|
||||
Discover AI-generated weather art from cities around the world
|
||||
</p>
|
||||
|
||||
<!-- 如果有特色图片,显示其信息 -->
|
||||
<% if featured_art %>
|
||||
<div class="text-sm text-base-content/60 pt-4">
|
||||
Latest from <%= featured_art.city.name %>, <%= featured_art.city.country.name %>
|
||||
<span class="mx-2">•</span>
|
||||
<%= featured_art.weather_date.strftime("%B %d, %Y") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选导航 -->
|
||||
<div class="container mx-auto px-4 -mt-8">
|
||||
<div class="bg-base-100 shadow-xl rounded-box p-6 mb-12">
|
||||
<!-- 筛选选项 -->
|
||||
<div class="flex flex-wrap gap-4 justify-center items-center">
|
||||
<!-- 时间排序 -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<%= params[:sort] == 'oldest' ? 'Oldest First' : 'Newest First' %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<%= link_to "Newest First", arts_path(sort: 'newest', region: params[:region]),
|
||||
class: "#{'active' if params[:sort] != 'oldest'}" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to "Oldest First", arts_path(sort: 'oldest', region: params[:region]),
|
||||
class: "#{'active' if params[:sort] == 'oldest'}" %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 区域筛选 -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<%= @current_region&.name || 'All Regions' %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<%= link_to "All Regions", arts_path(sort: params[:sort]),
|
||||
class: "#{'active' unless @current_region}" %>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<% @regions.each do |region| %>
|
||||
<li>
|
||||
<%= link_to region.name, arts_path(region: region.id, sort: params[:sort]),
|
||||
class: "#{'active' if @current_region == region}" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果统计 -->
|
||||
<div class="text-center text-sm text-base-content/70 mt-4">
|
||||
Showing <%= @weather_arts.total_count %> weather arts
|
||||
<% if @current_region %>
|
||||
from <%= @current_region.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 pb-16">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<% @weather_arts.each do |art| %>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 group overflow-hidden">
|
||||
<figure class="relative aspect-square overflow-hidden">
|
||||
<% if art.image.attached? %>
|
||||
<%= image_tag art.image,
|
||||
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<div class="absolute inset-0 p-6 flex flex-col justify-end translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<div class="text-white space-y-2">
|
||||
<h3 class="text-xl font-display font-bold">
|
||||
<%= art.city.name %>
|
||||
</h3>
|
||||
<p class="text-sm text-white/80">
|
||||
<%= art.city.country.name %>
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-white/90">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
<%= art.description %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</figure>
|
||||
|
||||
<!-- 信息部分 -->
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 class="font-display font-bold leading-tight">
|
||||
<%= art.city.name %>
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
<%= art.weather_date.strftime("%B %d, %Y") %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-primary">
|
||||
<%= art.temperature %>°C
|
||||
</div>
|
||||
<div class="text-sm text-base-content/70">
|
||||
<%= art.humidity %>% humidity
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= link_to city_weather_art_path(art.city, art),
|
||||
class: "btn btn-primary btn-sm w-full" do %>
|
||||
View Details
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/pagination',
|
||||
collection: @weather_arts,
|
||||
collection_name: 'weather arts' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,126 +0,0 @@
|
||||
<!-- app/views/cities/_city.html.erb -->
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 group overflow-hidden">
|
||||
<% if city.latest_weather_art&.image&.attached? %>
|
||||
<!-- 图片和主要信息区域 -->
|
||||
<div class="relative">
|
||||
<!-- 图片 -->
|
||||
<figure class="aspect-[16/9] overflow-hidden">
|
||||
<%= image_tag city.latest_weather_art.image,
|
||||
class: "w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-500" %>
|
||||
</figure>
|
||||
|
||||
<!-- 渐变遮罩 - 使用多层渐变提供更好的文字可读性 -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-transparent"></div>
|
||||
|
||||
<!-- 温度显示 - 放在右上角 -->
|
||||
<div class="absolute top-4 right-4 bg-black/30 backdrop-blur-sm rounded-xl px-3 py-2 text-white">
|
||||
<div class="text-2xl font-bold leading-none">
|
||||
<%= city.latest_weather_art.temperature %>°C
|
||||
</div>
|
||||
<div class="text-xs text-white/80">
|
||||
Feels <%= city.latest_weather_art.feeling_temp %>°C
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 城市名称和位置 - 放在底部 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6">
|
||||
<h3 class="text-2xl font-display text-white font-bold mb-2 drop-shadow-lg">
|
||||
<%= city.name %>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-white/90 text-sm drop-shadow-lg">
|
||||
<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>
|
||||
<%= city.country.name %>, <%= city.region.name %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息区域 -->
|
||||
<div class="card-body bg-base-100">
|
||||
<!-- 天气信息和更新时间 -->
|
||||
<div class="flex items-center justify-between text-sm text-base-content/70 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary/70" 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>
|
||||
<%= city.latest_weather_art.description %>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<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>
|
||||
<%= city.latest_weather_art.weather_date.strftime("%b %d, %Y") %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 天气详情 -->
|
||||
<div class="grid grid-cols-3 gap-2 mb-4">
|
||||
<div class="stat bg-base-200/50 rounded-lg p-2">
|
||||
<div class="stat-title text-xs">Humidity</div>
|
||||
<div class="stat-value text-lg"><%= city.latest_weather_art.humidity %>%</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200/50 rounded-lg p-2">
|
||||
<div class="stat-title text-xs">Wind</div>
|
||||
<div class="stat-value text-lg"><%= city.latest_weather_art.wind_scale %></div>
|
||||
</div>
|
||||
<div class="stat bg-base-200/50 rounded-lg p-2">
|
||||
<div class="stat-title text-xs">Visibility</div>
|
||||
<div class="stat-value text-lg"><%= city.latest_weather_art.visibility %>km</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮 -->
|
||||
<div class="card-actions justify-end">
|
||||
<%= link_to city_path(city),
|
||||
class: "btn btn-primary btn-sm gap-2" do %>
|
||||
View Details
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% else %>
|
||||
<!-- 无图片时的备用显示 -->
|
||||
<div class="card-body">
|
||||
<h3 class="card-title font-display text-2xl mb-3"><%= city.name %></h3>
|
||||
|
||||
<div class="flex items-center gap-2 text-base-content/70 mb-4">
|
||||
<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>
|
||||
<%= city.country.name %>, <%= city.region.name %>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-200 rounded-lg p-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary/70" 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>
|
||||
<span>Lat: <%= city.latitude %></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary/70" 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>
|
||||
<span>Long: <%= city.longitude %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<%= link_to city_path(city),
|
||||
class: "btn btn-primary btn-sm gap-2" do %>
|
||||
View Details
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
@ -1,126 +1,20 @@
|
||||
<!-- app/views/cities/index.html.erb -->
|
||||
<div class="min-h-screen">
|
||||
<!-- 页面标题和背景 -->
|
||||
<% featured_art = WeatherArt.includes(:city).joins(:image_attachment).order(created_at: :desc).first %>
|
||||
<div class="relative bg-base-100">
|
||||
<!-- 背景图像和渐变 -->
|
||||
<% if featured_art&.image&.attached? %>
|
||||
<div class="absolute inset-0 h-[60vh] overflow-hidden">
|
||||
<%= image_tag featured_art.image,
|
||||
class: "w-full h-full object-cover object-center" %>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-base-100/40 via-base-100/80 to-base-100"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">Cities</h1>
|
||||
|
||||
<div class="relative pt-24 pb-32">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="max-w-3xl mx-auto text-center space-y-6">
|
||||
<h1 class="text-5xl md:text-6xl font-display font-bold leading-tight">
|
||||
Explore Cities
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl text-base-content/70 font-light max-w-2xl mx-auto">
|
||||
Discover AI-generated weather art from cities around the world
|
||||
</p>
|
||||
|
||||
<!-- 特色图片信息 -->
|
||||
<% if featured_art %>
|
||||
<div class="inline-block mt-6 px-4 py-2 bg-base-100/80 backdrop-blur-sm rounded-full text-sm">
|
||||
Latest from
|
||||
<span class="font-semibold"><%= featured_art.city.name %></span>,
|
||||
<%= featured_art.city.country.name %>
|
||||
<span class="mx-2">•</span>
|
||||
<%= featured_art.weather_date.strftime("%B %d, %Y") %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @cities.each do |city| %>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title"><%= city.name %></h2>
|
||||
<p><%= city.country %></p>
|
||||
<div class="card-actions justify-end">
|
||||
<%= link_to 'View Weather History', city_path(city),
|
||||
class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky top-16 z-20 bg-base-100/95 backdrop-blur-sm border-b border-base-200">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="py-3 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<%= @current_region&.name || 'All Regions' %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<%= link_to cities_path,
|
||||
class: "#{@current_region ? '' : 'active'}" do %>
|
||||
All Regions
|
||||
<% end %>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<% @regions.each do |region| %>
|
||||
<li>
|
||||
<%= link_to region.name,
|
||||
cities_path(region: region.slug),
|
||||
class: "#{@current_region == region ? 'active' : ''}" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<% if @current_region %>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-ghost gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||
</svg>
|
||||
<%= @current_country&.name || "All Countries" %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<%= link_to "All in #{@current_region.name}",
|
||||
cities_path(region: @current_region.slug),
|
||||
class: "#{@current_country ? '' : 'active'}" %>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<% @current_region.countries.order(:name).each do |country| %>
|
||||
<li>
|
||||
<%= link_to country.name,
|
||||
cities_path(region: @current_region.slug, country: country.slug),
|
||||
class: "#{@current_country == country ? 'active' : ''}" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-base-content/70">
|
||||
<%= @cities.count %> <%= 'city'.pluralize(@cities.count) %>
|
||||
<% if @current_country %>
|
||||
in <%= @current_country.name %>
|
||||
<% elsif @current_region %>
|
||||
in <%= @current_region.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<%= render partial: 'city', collection: @cities %>
|
||||
</div>
|
||||
|
||||
<%= render 'shared/pagination',
|
||||
collection: @cities,
|
||||
collection_name: 'cities' %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<%= paginate @cities %>
|
||||
</div>
|
@ -1,149 +1,130 @@
|
||||
<div class="relative min-h-screen bg-base-200">
|
||||
<!-- 背景效果 -->
|
||||
<% if @city.latest_weather_art&.image&.attached? %>
|
||||
<div class="fixed inset-0 -z-10">
|
||||
<%= image_tag @city.latest_weather_art.image,
|
||||
class: "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-6">
|
||||
<%= link_to cities_path,
|
||||
class: "btn btn-ghost btn-lg gap-2 bg-base-100/50 backdrop-blur-sm hover:bg-base-100/70 transition-all duration-300" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Cities
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- app/views/cities/show.html.erb -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 城市信息头部 -->
|
||||
<div class="container mx-auto px-4 mb-12">
|
||||
<div class="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h1 class="text-4xl md:text-6xl font-display font-bold">
|
||||
<span class="bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
<%= @city.localized_name %>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap justify-center items-center gap-3">
|
||||
<div class="badge badge-lg badge-primary gap-2">
|
||||
<%= @city.country.name %>, <%= @city.region %>
|
||||
</div>
|
||||
<div class="badge badge-lg badge-secondary gap-2">
|
||||
<%= @city.timezone.present? ? Time.current.in_time_zone(@city.timezone).strftime("%Y-%m-%d %H:%M") : "Timezone undefined" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要统计信息 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-8">
|
||||
<div class="stat bg-base-100/80 backdrop-blur-sm shadow-lg rounded-box hover:bg-base-100/90 transition-all duration-300">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<%= weather_stat_icon("temperature") %>
|
||||
<div class="stat-title font-medium">Latest Weather</div>
|
||||
</div>
|
||||
<div class="stat-value text-2xl"><%= @city.latest_weather_art&.temperature %>°C</div>
|
||||
<div class="stat-desc mt-1"><%= @city.latest_weather_art&.description %></div>
|
||||
</div>
|
||||
|
||||
<div class="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 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 class="container mx-auto px-4 pb-16">
|
||||
<div class="max-w-7xl mx-auto space-y-8">
|
||||
<!-- 标题和更新时间 -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<h2 class="text-3xl font-display font-bold bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
||||
Weather Art History
|
||||
</h2>
|
||||
<div class="card bg-base-100/80 backdrop-blur-sm shadow-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div class="bg-base-200 rounded-box p-8 mb-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold mb-2"><%= @city.name %></h1>
|
||||
<div class="flex items-center gap-2 text-base-content/70">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" />
|
||||
</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>
|
||||
<span><%= @city.country %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 天气艺术卡片网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @city.weather_arts.order(weather_date: :desc).each do |art| %>
|
||||
<div class="card bg-base-100/80 backdrop-blur-sm shadow-xl hover:shadow-2xl transition-all duration-300 hover:-translate-y-1">
|
||||
<figure class="relative aspect-video overflow-hidden">
|
||||
<% if art.image.attached? %>
|
||||
<%= image_tag art.image,
|
||||
class: "w-full h-full object-cover transform hover:scale-105 transition-transform duration-500" %>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/70 to-transparent">
|
||||
<div class="flex items-center justify-between text-white">
|
||||
<div class="text-2xl font-bold"><%= art.temperature %>°C</div>
|
||||
<div class="text-right">
|
||||
<div class="font-medium"><%= art.weather_date.strftime("%H:%M") %></div>
|
||||
<div class="text-sm opacity-80"><%= art.weather_date.strftime("%B %d, %Y") %></div>
|
||||
<!-- 当前天气信息 -->
|
||||
<% if @current_weather_art = @city.current_weather_art %>
|
||||
<div class="stats shadow">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Temperature</div>
|
||||
<div class="stat-value"><%= @current_weather_art.temperature %>°C</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Condition</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期筛选器 -->
|
||||
<div class="flex flex-col md:flex-row gap-4 items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-2xl font-bold">Weather Art History</h2>
|
||||
<div class="badge badge-primary"><%= @weather_arts.total_count %> artworks</div>
|
||||
</div>
|
||||
|
||||
<%= form_tag city_path(@city), method: :get, class: "join" do %>
|
||||
<%= date_field_tag :start_date, params[:start_date],
|
||||
class: "join-item input input-bordered w-full max-w-xs",
|
||||
placeholder: "Start Date" %>
|
||||
<%= date_field_tag :end_date, params[:end_date],
|
||||
class: "join-item input input-bordered w-full max-w-xs",
|
||||
placeholder: "End Date" %>
|
||||
<%= submit_tag "Filter", class: "join-item btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- 天气艺术网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<% @weather_arts.each do |art| %>
|
||||
<div class="card bg-base-100 shadow-xl group hover:scale-105 transition-transform duration-200"
|
||||
data-controller="modal"
|
||||
data-action="click->modal#open">
|
||||
<!-- 卡片主体 -->
|
||||
<figure class="relative">
|
||||
<%= image_tag art.image_url,
|
||||
class: "w-full h-64 object-cover" %>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/70 to-transparent text-white">
|
||||
<div class="text-lg font-bold"><%= art.weather_date.strftime("%B %d, %Y") %></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span><%= art.temperature %>°C</span>
|
||||
<span class="text-xs">|</span>
|
||||
<span><%= art.description %></span>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<div class="card-body">
|
||||
<h3 class="card-title font-display">
|
||||
<%= weather_description_icon(art.description) %>
|
||||
<%= art.description %>
|
||||
<!-- 模态框内容 -->
|
||||
<dialog id="modal_<%= art.id %>" class="modal">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Weather Art - <%= art.weather_date.strftime("%B %d, %Y") %>
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 my-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<%= weather_stat_icon("humidity") %>
|
||||
<span>Humidity: <%= art.humidity %>%</span>
|
||||
<%= image_tag art.image_url, class: "w-full rounded-lg mb-4" %>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Temperature</div>
|
||||
<div class="stat-value text-lg"><%= art.temperature %>°C</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<%= weather_stat_icon("wind") %>
|
||||
<span>Wind: <%= art.wind_scale %></span>
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Humidity</div>
|
||||
<div class="stat-value text-lg"><%= art.humidity %>%</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Wind Speed</div>
|
||||
<div class="stat-value text-lg"><%= art.wind_speed %></div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-box">
|
||||
<div class="stat-title">Condition</div>
|
||||
<div class="stat-value text-lg"><%= art.description %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= link_to city_weather_art_path(@city, art),
|
||||
class: "btn btn-primary btn-block" do %>
|
||||
View Details
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
<div class="bg-base-200 p-4 rounded-box mb-4">
|
||||
<h4 class="font-bold mb-2">AI Prompt</h4>
|
||||
<p class="text-sm"><%= art.prompt %></p>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Close</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-center">
|
||||
<%= paginate @weather_arts %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stimulus Controller for Modal -->
|
||||
<%# app/javascript/controllers/modal_controller.js %>
|
||||
<script type="module">
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
open(event) {
|
||||
const modalId = this.element.querySelector('dialog').id
|
||||
const modal = document.getElementById(modalId)
|
||||
modal.showModal()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,68 +0,0 @@
|
||||
<div>
|
||||
<!-- 首屏展示区 -->
|
||||
<section class="h-screen-90 relative overflow-hidden">
|
||||
<% if @featured_arts.first&.image&.attached? %>
|
||||
<div class="absolute inset-0">
|
||||
<%= image_tag @featured_arts.first.image, class: "w-full h-full object-cover" %>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-base-100/90 to-base-100/50"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="container mx-auto px-4 h-full flex items-center relative">
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<h1 class="text-5xl md:text-6xl font-display font-bold leading-tight">
|
||||
Where Weather Meets<br>Artificial Intelligence
|
||||
</h1>
|
||||
<p class="text-xl text-base-content/70 font-sans">
|
||||
Experience weather through the lens of AI-generated art,
|
||||
bringing a new perspective to daily meteorological phenomena.
|
||||
</p>
|
||||
<%= link_to "Explore Cities", cities_path,
|
||||
class: "btn btn-primary btn-lg mt-8 font-sans" %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最新天气艺术 -->
|
||||
<section class="container mx-auto px-4 py-16 space-y-12">
|
||||
<h2 class="text-3xl font-display font-bold text-center">Shuffle Latest Weather Art</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<% @latest_arts.each do |art| %>
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300">
|
||||
<figure class="relative aspect-[4/3] overflow-hidden">
|
||||
<% if art.image.attached? %>
|
||||
<%= image_tag art.image, class: "w-full h-full object-cover transform hover:scale-105 transition-transform duration-500" %>
|
||||
<% end %>
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="card-title font-display"><%= art.city.name %></h3>
|
||||
<p class="text-base-content/70">
|
||||
<%= art.weather_date.strftime("%B %d, %Y") %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold"><%= art.temperature %>°C</div>
|
||||
<div class="text-sm text-base-content/70"><%= art.description %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<%= link_to "View Details", city_weather_art_path(art.city, art),
|
||||
class: "btn btn-primary btn-outline" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="text-center mt-12 mb-12">
|
||||
<%= link_to arts_path, class: "btn btn-primary btn-lg gap-2" do %>
|
||||
View All Weather Arts
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
@ -1,11 +1,3 @@
|
||||
<%# Link to the "First" page
|
||||
- available local variables
|
||||
url: url to the first page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="first">
|
||||
<%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %>
|
||||
</span>
|
||||
<button class="join-item btn">
|
||||
<%= link_to_unless current_page.first?, raw(t 'views.pagination.first'), url, remote: remote %>
|
||||
</button>
|
||||
|
@ -1,8 +1,3 @@
|
||||
<%# Non-link tag that stands for skipped pages...
|
||||
- available local variables
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="page gap"><%= t('views.pagination.truncate').html_safe %></span>
|
||||
<button class="join-item btn btn-disabled">
|
||||
<%= content_tag :a, raw(t 'views.pagination.truncate') %>
|
||||
</button>
|
||||
|
@ -1,11 +1,3 @@
|
||||
<%# Link to the "Last" page
|
||||
- available local variables
|
||||
url: url to the last page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="last">
|
||||
<%= link_to_unless current_page.last?, t('views.pagination.last').html_safe, url, remote: remote %>
|
||||
</span>
|
||||
<button class="join-item btn">
|
||||
<%= link_to_unless current_page.last?, raw(t 'views.pagination.last'), url, { remote: remote } %>
|
||||
</button>
|
||||
|
@ -1,11 +1,3 @@
|
||||
<%# Link to the "Next" page
|
||||
- available local variables
|
||||
url: url to the next page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="next">
|
||||
<%= link_to_unless current_page.last?, t('views.pagination.next').html_safe, url, rel: 'next', remote: remote %>
|
||||
</span>
|
||||
<button class="join-item btn">
|
||||
<%= link_to_unless current_page.last?, raw(t 'views.pagination.next'), url, rel: 'next', remote: remote %>
|
||||
</button>
|
||||
|
@ -1,12 +1,9 @@
|
||||
<%# Link showing page number
|
||||
- available local variables
|
||||
page: a page object for "this" page
|
||||
url: url to this page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="page<%= ' current' if page.current? %>">
|
||||
<%= link_to_unless page.current?, page, url, {remote: remote, rel: page.rel} %>
|
||||
</span>
|
||||
<% if page.current? %>
|
||||
<button class='join-item btn btn-active'>
|
||||
<%= content_tag :a, page, data: { remote: remote }, rel: page.rel %>
|
||||
</button>
|
||||
<% else %>
|
||||
<button class="join-item btn">
|
||||
<%= link_to page, url, remote: remote, rel: page.rel %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
@ -1,25 +1,15 @@
|
||||
<%# The container tag
|
||||
- available local variables
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
paginator: the paginator that renders the pagination tags inside
|
||||
-%>
|
||||
<%= paginator.render do -%>
|
||||
<nav class="pagination" role="navigation" aria-label="pager">
|
||||
<div class="join pagination">
|
||||
<%= first_page_tag unless current_page.first? %>
|
||||
<%= prev_page_tag unless current_page.first? %>
|
||||
<% each_page do |page| -%>
|
||||
<% if page.display_tag? -%>
|
||||
<% if page.left_outer? || page.right_outer? || page.inside_window? -%>
|
||||
<%= page_tag page %>
|
||||
<% elsif !page.was_truncated? -%>
|
||||
<%= gap_tag %>
|
||||
<% end -%>
|
||||
<% end -%>
|
||||
<% unless current_page.out_of_range? %>
|
||||
<%= next_page_tag unless current_page.last? %>
|
||||
<%= last_page_tag unless current_page.last? %>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<% end -%>
|
||||
|
@ -1,11 +1,3 @@
|
||||
<%# Link to the "Previous" page
|
||||
- available local variables
|
||||
url: url to the previous page
|
||||
current_page: a page object for the currently displayed page
|
||||
total_pages: total number of pages
|
||||
per_page: number of items to fetch per page
|
||||
remote: data-remote
|
||||
-%>
|
||||
<span class="prev">
|
||||
<%= link_to_unless current_page.first?, t('views.pagination.previous').html_safe, url, rel: 'prev', remote: remote %>
|
||||
</span>
|
||||
<button class="join-item btn">
|
||||
<%= link_to_unless current_page.first?, raw(t 'views.pagination.previous'), url, rel: 'prev', remote: remote %>
|
||||
</button>
|
||||
|
8
app/views/layouts/_footer.html.erb
Normal file
8
app/views/layouts/_footer.html.erb
Normal file
@ -0,0 +1,8 @@
|
||||
<footer class="footer p-10 bg-neutral text-neutral-content">
|
||||
<div>
|
||||
<span class="footer-title">AI Weather Art</span>
|
||||
<p>Daily weather-inspired AI artwork for cities worldwide</p>
|
||||
<p>Copyright © <%= Date.current.year %> - All right reserved by ACME Industries Ltd</p>
|
||||
</div>
|
||||
|
||||
</footer>
|
90
app/views/layouts/_header.html.erb
Normal file
90
app/views/layouts/_header.html.erb
Normal file
@ -0,0 +1,90 @@
|
||||
<!-- 固定在顶部容器 -->
|
||||
<div class="fixed top-0 left-0 right-0 z-50">
|
||||
<!-- 响应式内边距 -->
|
||||
<!--<div class="container mx-auto px-3 sm:px-6 lg:px-1 py-4">-->
|
||||
<!-- 顶部菜单栏 -->
|
||||
<div class="navbar
|
||||
backdrop-filter backdrop-blur-lg bg-opacity-30 border-b border-gray-200 border-transparent
|
||||
shadow-md min-h-0 h-12 px-8 lg:px-12">
|
||||
<div class="navbar-start">
|
||||
<%= link_to "Today AI Weather", root_url, id: "logo", class: "btn btn-ghost text-xl" %>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><%= link_to "Home", root_url %></li>
|
||||
<li><%= link_to "Help", help_url %></li>
|
||||
<li><%= link_to 'Calendar', "#" %></li>
|
||||
<li><%= link_to 'Cities', "#" %></li>
|
||||
<% if logged_in? %>
|
||||
<li><%= link_to "Users", users_path %></li>
|
||||
<li>
|
||||
<details>
|
||||
<summary>
|
||||
Account <b class="caret"></b>
|
||||
</summary>
|
||||
<ul class="bg-base-100 rounded-t-none p-2">
|
||||
<li><%= link_to "Profile", current_user %></li>
|
||||
<li><%= link_to "Settings", edit_user_path(current_user) %></li>
|
||||
<div class="divider"></div>
|
||||
<li>
|
||||
<%= link_to "Log out", logout_path, data: { turbo_method: :delete } %>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<% else %>
|
||||
<li><%= link_to "Log in", login_path %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<details class="dropdown dropdown-end">
|
||||
<summary tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<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="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
</summary>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow">
|
||||
<li><%= link_to "Home", root_url %></li>
|
||||
<li><%= link_to "Help", help_url %></li>
|
||||
<li><%= link_to 'Cities', "#" %></li>
|
||||
<li><%= link_to 'Calendar', "#" %></li>
|
||||
<% if logged_in? %>
|
||||
<li><%= link_to "Users", users_path %></li>
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
Account <b class="caret"></b>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><%= link_to "Profile", current_user %></li>
|
||||
<li><%= link_to "Settings", edit_user_path(current_user) %></li>
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<%= link_to "Log out", logout_path, data: { turbo_method: :delete } %>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<% else %>
|
||||
<li><%= link_to "Log in", login_path %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</details>
|
||||
<%= render "layouts/theme_swap" %>
|
||||
</div>
|
||||
</div>
|
||||
<!--</div>-->
|
||||
</div>
|
||||
|
||||
<!-- 添加一个占位 div,防止内容被覆盖 -->
|
||||
<div aria-hidden="true" class="border-none h-12"></div>
|
37
app/views/layouts/_message.html.erb
Normal file
37
app/views/layouts/_message.html.erb
Normal file
@ -0,0 +1,37 @@
|
||||
<!-- 放在布局文件的 body 标签末尾 -->
|
||||
<div class="toast toast-end">
|
||||
<% flash.each do |message_type, message| %>
|
||||
<% alert_class = case message_type
|
||||
when "success" then "alert alert-success"
|
||||
when "danger", "error" then "alert alert-error"
|
||||
when "warning" then "alert alert-warning"
|
||||
when "info" then "alert alert-info"
|
||||
else "alert"
|
||||
end
|
||||
%>
|
||||
|
||||
<div data-controller="toast" class="<%= alert_class %> animate-slide-in-right">
|
||||
<div>
|
||||
<% case message_type %>
|
||||
<% when "success" %>
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<% when "error", "danger" %>
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<% when "warning" %>
|
||||
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<% 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>
|
||||
<% end %>
|
||||
<span><%= message %></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
21
app/views/layouts/_rails_default.html.erb
Normal file
21
app/views/layouts/_rails_default.html.erb
Normal file
@ -0,0 +1,21 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= yield :head %>
|
||||
|
||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
||||
|
||||
<link rel="icon" href="/icon.png" type="image/png">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.png">
|
||||
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%#= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
<%#= stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
|
||||
<%#= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %>
|
||||
<%#= javascript_importmap_tags %>
|
4
app/views/layouts/_shim.html.erb
Normal file
4
app/views/layouts/_shim.html.erb
Normal file
@ -0,0 +1,4 @@
|
||||
<!--[if lt IE 9]>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/r29/html5.min.js">
|
||||
</script>
|
||||
<![endif]-->
|
29
app/views/layouts/_theme_swap.html.erb
Normal file
29
app/views/layouts/_theme_swap.html.erb
Normal file
@ -0,0 +1,29 @@
|
||||
<div data-controller="theme">
|
||||
<label class="swap swap-rotate">
|
||||
<!-- this hidden checkbox controls the state -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="theme-controller"
|
||||
data-theme-target="toggle"
|
||||
data-action="change->theme#toggle"
|
||||
/>
|
||||
|
||||
<!-- sun icon -->
|
||||
<svg
|
||||
class="swap-off h-6 w-6 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
|
||||
</svg>
|
||||
|
||||
<!-- moon icon -->
|
||||
<svg
|
||||
class="swap-on h-6 w-6 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
@ -1,98 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><%= content_for(:title) || "Today Ai Weather" %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<%= display_meta_tags(
|
||||
site: 'TodayAIWeather',
|
||||
reverse: true,
|
||||
og: {
|
||||
site_name: 'TodayAIWeather',
|
||||
type: 'website',
|
||||
url: request.original_url
|
||||
},
|
||||
alternate: {
|
||||
"zh-CN" => url_for(locale: 'zh-CN'),
|
||||
"en" => url_for(locale: 'en')
|
||||
}
|
||||
) %>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= yield :head %>
|
||||
|
||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
||||
|
||||
<link rel="icon" href="/icon.png" type="image/png">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.png">
|
||||
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<!-- <title><%#= content_for(:title) || "Sample App" %></title>-->
|
||||
<title><%= full_title(yield(:title)) %></title>
|
||||
<%= render 'layouts/rails_default' %>
|
||||
<%= render 'layouts/shim' %>
|
||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<script defer data-domain="todayaiweather.com" src="https://plausible.frytea.com/js/script.js"></script>
|
||||
|
||||
<script defer src="https://busuanzi.frytea.com/js"></script>
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-PX1C92V5L7"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-PX1C92V5L7');
|
||||
</script>
|
||||
|
||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-7296634171837358"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-base-100 font-sans">
|
||||
<!-- 导航栏 -->
|
||||
<div class="navbar bg-base-100/80 backdrop-blur-sm fixed top-0 z-50">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex-1">
|
||||
<%= link_to root_path, class: "text-2xl font-display font-bold hover:text-primary transition-colors" do %>
|
||||
Today AI Weather
|
||||
<body>
|
||||
<%= render 'layouts/header' %>
|
||||
<div class="">
|
||||
<%= render 'layouts/message' %>
|
||||
<%= yield %>
|
||||
<%= render 'layouts/footer' %>
|
||||
<%#= debug(params) if Rails.env.development? %>
|
||||
<%#= debug(params.to_yaml) if Rails.env.development? %>
|
||||
<%#= debug(session) if Rails.env.development? %>
|
||||
<%#= debug(Time.now) if Rails.env.development? %>
|
||||
|
||||
<% if Rails.env.development? %>
|
||||
<div class="container mx-auto px-4 mb-8 pt-4">
|
||||
<div class="max-w-4xl mx-auto border-2 border-gray-300 rounded-lg p-6 bg-gray-100">
|
||||
<!-- Params -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-gray-700 font-bold mb-2">Params:</h3>
|
||||
<pre class="bg-white p-4 rounded shadow overflow-x-auto text-sm text-gray-600 border border-gray-200">
|
||||
<%= params.to_yaml %>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<!-- Session -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-gray-700 font-bold mb-2">Session:</h3>
|
||||
<pre class="bg-white p-4 rounded shadow overflow-x-auto text-sm text-gray-600 border border-gray-200">
|
||||
<%= session %>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div>
|
||||
<h3 class="text-gray-700 font-bold mb-2">Current Time:</h3>
|
||||
<pre class="bg-white p-4 rounded shadow overflow-x-auto text-sm text-gray-600 border border-gray-200">
|
||||
<%= Time.now %>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<%= link_to "Cities", cities_path, class: "btn btn-ghost font-sans" %>
|
||||
<%= link_to "Arts", arts_path, class: "btn btn-ghost font-sans" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<main class="pt-16">
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer footer-center p-8 bg-base-200 text-base-content">
|
||||
<div class="container mx-auto flex flex-col gap-2">
|
||||
<div id="busuanzi_container" class="text-xs opacity-70">
|
||||
<div class="space-x-2">
|
||||
<span>Page Views: <span id="busuanzi_page_pv"></span></span>
|
||||
<span>|</span>
|
||||
<span>Page Visitors: <span id="busuanzi_page_uv"></span></span>
|
||||
<span>|</span>
|
||||
<span>Total Views: <span id="busuanzi_site_pv"></span></span>
|
||||
<span>|</span>
|
||||
<span>Total Visitors: <span id="busuanzi_site_uv"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="font-display opacity-80">
|
||||
Copyright © 2024 - All rights reserved by AI Weather Art
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
21
app/views/password_resets/edit.html.erb
Normal file
21
app/views/password_resets/edit.html.erb
Normal file
@ -0,0 +1,21 @@
|
||||
<% provide(:title, "Reset password") %>
|
||||
<h1>Reset password</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<%= form_with(model: @user, url: password_reset_path(params[:id]),
|
||||
local: true) do |f| %>
|
||||
<%= render 'shared/error_messages' %>
|
||||
|
||||
<%= hidden_field_tag :email, @user.email %>
|
||||
|
||||
<%= f.label :password %>
|
||||
<%= f.password_field :password, class: 'form-control' %>
|
||||
|
||||
<%= f.label :password_confirmation, "Confirmation" %>
|
||||
<%= f.password_field :password_confirmation, class: "form-control" %>
|
||||
|
||||
<%= f.submit "Update password", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
14
app/views/password_resets/new.html.erb
Normal file
14
app/views/password_resets/new.html.erb
Normal file
@ -0,0 +1,14 @@
|
||||
<% provide(:title, "Forget password") %>
|
||||
<h1>Forget password</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="vol-md-6 col-md-offset-3">
|
||||
<%= form_with(url: password_resets_path, scope: :password_reset,
|
||||
local: true) do |f| %>
|
||||
<%= f.label :email %>
|
||||
<%= f.email_field :email, class: "form-control" %>
|
||||
|
||||
<%= f.submit "Submit", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "TodayAiWeather",
|
||||
"name": "SampleApp",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.png",
|
||||
@ -16,7 +16,7 @@
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"description": "TodayAiWeather.",
|
||||
"description": "SampleApp.",
|
||||
"theme_color": "red",
|
||||
"background_color": "red"
|
||||
}
|
||||
|
45
app/views/sessions/new.html.erb
Normal file
45
app/views/sessions/new.html.erb
Normal file
@ -0,0 +1,45 @@
|
||||
<% provide(:title, "Log in") %>
|
||||
|
||||
<div class ="container mx-auto px-4">
|
||||
<h1 class="text-3xl font-bold text-center my-8">Log in</h1>
|
||||
|
||||
<div class="max-w-md mx-auto">
|
||||
<%= form_with(url: login_path, scope: :session, local: true, class: "space-y-4") do |f| %>
|
||||
<div class="form-control">
|
||||
<%= f.label :email, class: "label" do %>
|
||||
<span class="label-text">Email</span>
|
||||
<% end %>
|
||||
<%= f.email_field :email, class: "input input-bordered w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between items-center">
|
||||
<%= f.label :password, class: "label" do %>
|
||||
<span class="label-text">Password</span>
|
||||
<% end %>
|
||||
<%= link_to "(forgot password)", new_password_reset_path,
|
||||
class: "text-sm text-primary hover:text-primary-focus" %>
|
||||
</div>
|
||||
<%= f.password_field :password, class: "input input-bordered w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<%= f.check_box :remember_me, class: "checkbox checkbox-primary" %>
|
||||
<span class="label-text ml-2">Remember me on this computer</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-6">
|
||||
<%= f.submit "Log in", class: "btn btn-primary w-full" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<p>New user?
|
||||
<%= link_to "Sign up now!", signup_path,
|
||||
class: "text-primary hover:text-primary-focus" %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
12
app/views/shared/_error_messages.html.erb
Normal file
12
app/views/shared/_error_messages.html.erb
Normal file
@ -0,0 +1,12 @@
|
||||
<% if @user.errors.any? %>
|
||||
<div id="error_explanation">
|
||||
<div class="alert alert-danger">
|
||||
The form contains <%= pluralize(@user.errors.count, "error") %>.
|
||||
</div>
|
||||
<ul>
|
||||
<% @user.errors.full_messages.each do |msg| %>
|
||||
<li><%= msg %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
@ -1,86 +0,0 @@
|
||||
<%# app/views/shared/_pagination.html.erb %>
|
||||
<% if collection.total_pages > 1 %>
|
||||
<div class="flex flex-col items-center mt-16 gap-6">
|
||||
<!-- 页码信息 -->
|
||||
<div class="text-base-content/70 font-light">
|
||||
<span class="px-4 py-2 bg-base-200/50 rounded-full">
|
||||
Page <%= collection.current_page %> of <%= collection.total_pages %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<div class="join shadow-lg">
|
||||
<!-- 首页 -->
|
||||
<%= link_to url_for(page: 1, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.first_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
|
||||
<!-- 上一页 -->
|
||||
<%= link_to url_for(page: collection.prev_page || 1, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.first_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
|
||||
<!-- 页码 -->
|
||||
<% page_window = 2 # 当前页面前后显示的页码数 %>
|
||||
<% start_page = [1, collection.current_page - page_window].max %>
|
||||
<% end_page = [collection.total_pages, collection.current_page + page_window].min %>
|
||||
|
||||
<% if start_page > 1 %>
|
||||
<%= link_to 1, url_for(page: 1, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs btn-ghost hover:bg-primary/5" %>
|
||||
<% if start_page > 2 %>
|
||||
<button class="join-item btn btn-xs btn-ghost btn-disabled">...</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% (start_page..end_page).each do |page| %>
|
||||
<% if page == collection.current_page %>
|
||||
<button class="join-item btn btn-xs btn-ghost bg-primary/10 font-medium">
|
||||
<%= page %>
|
||||
</button>
|
||||
<% else %>
|
||||
<%= link_to page, url_for(page: page, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs btn-ghost hover:bg-primary/5" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if end_page < collection.total_pages %>
|
||||
<% if end_page < collection.total_pages - 1 %>
|
||||
<button class="join-item btn btn-xs btn-ghost btn-disabled">...</button>
|
||||
<% end %>
|
||||
<%= link_to collection.total_pages,
|
||||
url_for(page: collection.total_pages, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs btn-ghost hover:bg-primary/5" %>
|
||||
<% end %>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<%= link_to url_for(page: collection.next_page || collection.total_pages, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.last_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
|
||||
<!-- 末页 -->
|
||||
<%= link_to url_for(page: collection.total_pages, region: params[:region], country: params[:country], sort: params[:sort]),
|
||||
class: "join-item btn btn-xs #{collection.last_page? ? 'btn-disabled' : 'btn-ghost'}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- 结果统计 -->
|
||||
<div class="text-sm text-base-content/60 font-light">
|
||||
Showing <%= collection.offset_value + 1 %> to
|
||||
<%= collection.last_page? ? collection.total_count : collection.offset_value + collection.limit_value %>
|
||||
of <%= collection.total_count %> <%= collection_name || 'items' %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
11
app/views/static_pages/about.html.erb
Normal file
11
app/views/static_pages/about.html.erb
Normal file
@ -0,0 +1,11 @@
|
||||
<% provide(:title, "About") %>
|
||||
<h1>About</h1>
|
||||
<p>
|
||||
The <a href="https://www.railstutorial.org/"><em>Ruby on Rails Tutorial</em></a>
|
||||
, part of the <a href="https://www.learnenough.com/">Learn Enough</a> family of
|
||||
tutorials, is a <a href="https://www.railstutorial.org/book">book</a> and
|
||||
<a href="https://screencasts.railstutorial.org/">screencast series</a>
|
||||
to teach web development with <a href="https://rubyonrails.org/">Ruby on Rails</a>.
|
||||
This is the sample app for the tutorial.
|
||||
</p>
|
||||
|
8
app/views/static_pages/contact.html.erb
Normal file
8
app/views/static_pages/contact.html.erb
Normal file
@ -0,0 +1,8 @@
|
||||
<% provide(:title, "Contact") %>
|
||||
<h1>Contact</h1>
|
||||
<p>
|
||||
Contact the Ruby on Rails Tutorial about the sample app at the
|
||||
<a href="https://www.railstutorial.org/contact">contact page</a>.
|
||||
|
||||
</p>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user