Commit 68348823 by Ivan

fix: 修复滚动加载 page 初始化大于 1 的情况

parent 24f5149a
class ApplicationController < ActionController::Base
include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
# allow_browser versions: :modern
inertia_share do
{
auth: authenticated?.user&.as_json(only: [ :id, :name, :email_address, :admin ])
auth: authenticated?.user&.as_json(only: [ :id, :name, :email_address, :roles ])
}
end
end
class ImagesController < ApplicationController
# Allow unauthenticated access to index and show actions
# allow_unauthenticated_access only: [:index, :show, :search]
before_action :set_image, only: [:show, :edit, :update, :destroy, :approve, :reject]
before_action :authorize_admin, only: [:approve, :reject, :destroy]
before_action :set_image, only: [ :show, :edit, :update, :destroy, :approve, :reject ]
before_action :authorize_admin, only: [ :approve, :reject, :destroy ]
def index
@q = Image.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(12)
render inertia: 'images/Index', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {}
}
respond_to do |format|
format.html do
render inertia: "images/Index", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
filters: params[:q] || {},
pagination: {
current_page: @images.current_page,
total_pages: @images.total_pages,
total_count: @images.total_count
}
}
end
format.json do
render json: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
pagination: {
current_page: @images.current_page,
total_pages: @images.total_pages,
total_count: @images.total_count
}
}
end
end
end
def show
render inertia: 'images/Show', props: {
image: @image.as_json(include: [:user, :tags], methods: [:file_url]),
render inertia: "images/Show", props: {
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
can_edit: Current.user == @image.user,
can_approve: Current.user&.admin?
}
......@@ -25,20 +45,20 @@ class ImagesController < ApplicationController
def new
@image = Image.new
render inertia: 'images/New'
render inertia: "images/New"
end
def create
@image = Current.user.images.new(image_params)
if @image.save
if params[:tags].present?
@image.add_tags(params[:tags].split(','))
@image.add_tags(params[:tags].split(","))
end
redirect_to image_path(@image), notice: 'Image was successfully uploaded and is pending review.'
redirect_to image_path(@image), notice: "Image was successfully uploaded and is pending review."
else
render inertia: 'images/New', props: {
image: @image.as_json(methods: [:errors]),
render inertia: "images/New", props: {
image: @image.as_json(methods: [ :errors ]),
errors: @image.errors
}, status: :unprocessable_entity
end
......@@ -46,24 +66,24 @@ class ImagesController < ApplicationController
def edit
authorize_user
render inertia: 'images/Edit', props: {
image: @image.as_json(include: [:tags], methods: [:file_url]),
tags: @image.tags.pluck(:name).join(', ')
render inertia: "images/Edit", props: {
image: @image.as_json(include: [ :tags ], methods: [ :file_url ]),
tags: @image.tags.pluck(:name).join(", ")
}
end
def update
authorize_user
if @image.update(image_params)
if params[:tags].present?
@image.tags.clear
@image.add_tags(params[:tags].split(','))
@image.add_tags(params[:tags].split(","))
end
redirect_to image_path(@image), notice: 'Image was successfully updated.'
redirect_to image_path(@image), notice: "Image was successfully updated."
else
render inertia: 'images/Edit', props: {
image: @image.as_json(include: [:tags], methods: [:file_url, :errors]),
render inertia: "images/Edit", props: {
image: @image.as_json(include: [ :tags ], methods: [ :file_url, :errors ]),
tags: params[:tags],
errors: @image.errors
}, status: :unprocessable_entity
......@@ -72,49 +92,49 @@ class ImagesController < ApplicationController
def destroy
@image.destroy
redirect_to images_path, notice: 'Image was successfully deleted.'
redirect_to images_path, notice: "Image was successfully deleted."
end
def search
@q = Image.approved.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(12)
render inertia: 'images/Search', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
render inertia: "images/Search", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
filters: params[:q] || {},
tags: Tag.all.pluck(:name)
}
end
def approve
@image.approved!
redirect_to image_path(@image), notice: 'Image was successfully approved.'
redirect_to image_path(@image), notice: "Image was successfully approved."
end
def reject
@image.rejected!
redirect_to image_path(@image), notice: 'Image was rejected.'
redirect_to image_path(@image), notice: "Image was rejected."
end
private
def set_image
@image = Image.find(params[:id])
end
def image_params
params.require(:image).permit(:title, :file)
end
def authorize_user
unless Current.user == @image.user
redirect_to images_path, alert: 'You are not authorized to perform this action.'
redirect_to images_path, alert: "You are not authorized to perform this action."
end
end
def authorize_admin
unless Current.user&.admin?
redirect_to images_path, alert: 'You are not authorized to perform this action.'
redirect_to images_path, alert: "You are not authorized to perform this action."
end
end
end
......@@ -166,7 +166,7 @@ export default function Layout({ children, user }) {
Upload
</Link>
)}
{user?.role === 'admin' && (
{user?.roles?.includes('admin') && (
<Link
href="/admin"
className="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium"
......
import { useState } from 'react'
import { Head, Link } from '@inertiajs/react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { Head, Link, router } from '@inertiajs/react'
import Layout from '../Layout'
import * as Tabs from '@radix-ui/react-tabs'
import * as Form from '@radix-ui/react-form'
export default function Index({ images, filters, auth }) {
export default function Index({ images, filters, pagination, auth }) {
const [allImages, setAllImages] = useState(images || [])
const [page, setPage] = useState(pagination?.current_page || 1)
const [maxVisiblePage, setMaxVisiblePage] = useState(0)
console.log('auth', auth)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(pagination?.current_page < pagination?.total_pages)
const [loadedPageMap, setLoadedPageMap] = useState({})
const bottomObserver = useRef()
const [searchParams, setSearchParams] = useState({
title_cont: filters.title_cont || '',
tags_name_cont: filters.tags_name_cont || '',
......@@ -19,8 +25,131 @@ console.log('auth', auth)
setSearchParams({ ...searchParams, [name]: value })
}
// const loadMoreImages = useCallback(() => {
// if (loading || !hasMore) return
// try {
// const nextPage = page + 1
// if (loadedPageMap[nextPage]) return
// setLoading(true)
// // Use Inertia's router.visit with replace option to avoid changing URL
// router.visit(
// '/images',
// {
// method: 'get',
// data: { page: nextPage },
// preserveState: true,
// preserveScroll: true,
// replace: true, // Replace current URL instead of adding to history
// only: ['images', 'pagination'],
// onSuccess: (response) => {
// // Append new images to existing ones
// setPage(response.props.pagination.current_page)
// setHasMore(response.props.pagination.current_page < response.props.pagination.total_pages)
// setLoading(false)
// setLoadedPageMap({ ...loadedPageMap, [response.props.pagination.current_page]: response.props.images })
// },
// onError: () => {
// console.error('Error loading more images')
// setLoading(false)
// }
// }
// )
// } catch (error) {
// console.error('Error loading more images:', error)
// setLoading(false)
// }
// }, [page, loading, hasMore])
const loadPage = ((currentPage) => {
if (loadedPageMap[currentPage]) {
return loadedPageMap[currentPage];
}
try {
setLoading(true)
router.visit(
'/images',
{
method: 'get',
data: { page: currentPage },
preserveState: true,
preserveScroll: true,
replace: true,
only: ['images', 'pagination'],
onSuccess: (response) => {
// Prepend new images to existing ones
setPage(response.props.pagination.current_page)
setLoading(false)
setHasMore(maxVisiblePage < response.props.pagination.total_pages)
setLoadedPageMap({...loadedPageMap, [response.props.pagination.current_page]: response.props.images})
},
onError: () => {
console.error('Error loading images')
setLoading(false)
}
})
} catch (error) {
console.error('Error loading images:', error)
setLoading(false)
}
})
const setVisibleImages = async (expectPage) =>{
if (!loadedPageMap[expectPage] && expectPage > maxVisiblePage) {
await loadPage(expectPage)
}
setAllImages([...allImages, ...loadedPageMap[expectPage]])
setMaxVisiblePage(expectPage)
}
useEffect(() => {
if (page >= 1 && !loadedPageMap[1]) {
loadPage(1)
setMaxVisiblePage(1)
} else {
loadPage(page)
setVisibleImages(page)
}
}, [page, loadedPageMap, maxVisiblePage])
const loadMoreImages = useCallback(() => {
loadPage(page + 1)
}, [page, loadPage])
// Observer for the last image (bottom of the page)
const lastImageElementRef = useCallback(node => {
if (loading) return
if (bottomObserver.current) bottomObserver.current.disconnect()
bottomObserver.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
loadMoreImages()
}
}, { threshold: 0.1, rootMargin: '100px' })
if (node) bottomObserver.current.observe(node)
}, [loading, hasMore, loadMoreImages])
// Observer for the first image (top of the page)
// const firstImageElementRef = useCallback(node => {
// if (loading) return
// if (topObserver.current) topObserver.current.disconnect()
// topObserver.current = new IntersectionObserver(entries => {
// if (entries[0].isIntersecting && hasPrevious && page > 1) {
// loadPreviousImages()
// }
// }, { threshold: 0.5, rootMargin: '200px' })
// if (node) topObserver.current.observe(node)
// }, [loading, hasPrevious, page, loadPreviousImages])
return (
<Layout user={auth.user}>
<Layout user={auth}>
<Head title="My Images" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
......@@ -57,15 +186,26 @@ console.log('auth', auth)
</Tabs.List>
<Tabs.Content value="all" className="focus:outline-none">
{images.length > 0 ? (
{allImages.length > 0 ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{images.map((image) => (
{loading && (
<div className="col-span-full text-center py-4">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" role="status">
<span className="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]">
Loading...
</span>
</div>
</div>
)}
{allImages.map((image, index) => (
<div
key={image.id}
ref={index === allImages.length - 1 ? lastImageElementRef : null}
className="bg-white overflow-hidden shadow rounded-lg"
>
<div className="relative pb-[75%]">
<img
loading="lazy"
src={image.file_url}
alt={image.title}
className="absolute h-full w-full object-cover"
......@@ -115,34 +255,53 @@ console.log('auth', auth)
</div>
) : (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No images
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by uploading a new image.
</p>
<div className="mt-6">
<Link
href="/images/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
{loading && allImages.length === 0 ? (
<div className="flex justify-center items-center">
<svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
) : (
<div>
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
Upload New Image
</Link>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No images
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by uploading a new image.
</p>
<div className="mt-6">
<Link
href="/images/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Upload New Image
</Link>
</div>
</div>
)}
</div>
)}
{loading && allImages.length > 0 && (
<div className="flex justify-center mt-6 pb-6">
<svg className="animate-spin h-8 w-8 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
)}
</Tabs.Content>
......
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import { Head, useForm, router } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
export default function New({ auth, errors = {} }) {
const [preview, setPreview] = useState(null)
const { data, setData, post, processing } = useForm({
const { data, setData, processing } = useForm({
title: '',
file: null,
tags: '',
......@@ -13,7 +13,7 @@ export default function New({ auth, errors = {} }) {
const handleSubmit = (e) => {
e.preventDefault()
post('/images')
router.post('/images', { image: data })
}
const handleFileChange = (e) => {
......
......@@ -16,6 +16,19 @@ class Image < ApplicationRecord
scope :approved, -> { where(status: :approved) }
scope :rejected, -> { where(status: :rejected) }
def self.ransackable_attributes(auth_object = nil)
[ "created_at", "id", "id_value", "status", "title", "updated_at", "user_id" ]
end
def self.ransackable_associations(auth_object = nil)
["image_tags", "tags", "user"]
end
# Returns the URL for the attached file
def file_url
file.attached? ? Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true) : nil
end
# Method to add tags to an image
def add_tags(tag_names)
tag_names.each do |name|
......
......@@ -6,6 +6,10 @@ class Tag < ApplicationRecord
before_save :downcase_name
def self.ransackable_attributes(auth_object = nil)
["created_at", "id", "id_value", "status", "name", "updated_at", "user_id"]
end
private
def downcase_name
......
......@@ -14,13 +14,39 @@ class User < ApplicationRecord
attribute :notify_on_new_login, :boolean, default: true
attribute :max_sessions, :integer, default: 5
# Admin role
# enum role: { user: 0, admin: 1 }, _default: :user
# 确保 roles 字段始终是数组
# attribute :roles, :json, default: -> { [] }
serialize :roles, coder: JSON, yaml: true
# 定义可用的角色
AVAILABLE_ROLES = %w[admin editor viewer]
# 添加角色
def add_role(role)
return unless AVAILABLE_ROLES.include?(role.to_s)
current_roles = (roles || []).map(&:to_s)
current_roles << role.to_s unless current_roles.include?(role.to_s)
update(roles: current_roles)
end
# 移除角色
def remove_role(role)
current_roles = (roles || []).map(&:to_s)
current_roles.delete(role.to_s)
update(roles: current_roles)
end
# 检查是否有指定角色
def has_role?(role)
Rails.logger.info("roles#{roles.class}")
(roles || []).map(&:to_s).include?(role.to_s)
end
# 向后兼容的 admin? 方法
def admin?
role == "admin"
has_role?("admin")
end
def security_settings
{
require_two_factor: require_two_factor,
......@@ -29,20 +55,20 @@ class User < ApplicationRecord
max_sessions: max_sessions
}
end
def enforce_max_sessions
return unless max_sessions > 0
# Get all sessions except the current one, ordered by last activity
other_sessions = sessions.where.not(id: Current.session&.id).order(updated_at: :desc)
# If we have more sessions than allowed, destroy the oldest ones
if other_sessions.count >= max_sessions
sessions_to_remove = other_sessions.offset(max_sessions - 1)
sessions_to_remove.destroy_all
end
end
def notify_on_new_login?
notify_on_new_login
end
......
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end
class AddRolesToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :roles, :string, default: '[]'
# 如果之前有 role 字段,我们需要将其移除
# 注意:这里假设之前没有 role 字段,如果有的话会自动处理
remove_column :users, :role if column_exists?(:users, :role)
end
end
......@@ -10,7 +10,35 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_08_064523) do
ActiveRecord::Schema[8.0].define(version: 2025_03_08_073302) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "image_tags", force: :cascade do |t|
t.integer "image_id", null: false
t.integer "tag_id", null: false
......@@ -50,9 +78,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_08_064523) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name"
t.string "roles", default: "[]"
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "image_tags", "images"
add_foreign_key "image_tags", "tags"
add_foreign_key "images", "users"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment