Commit 75d4a205 by Ivan

feat: 标签 & 修复文件页面

parent 6f2e33b4
class Admin::ImagesController < ApplicationController
before_action :authorize_admin
before_action :set_image, only: [:show, :update, :destroy, :approve, :reject, :add_tags, :remove_tag]
before_action :set_image, only: [ :show, :update, :destroy, :approve, :reject, :add_tags, :remove_tag ]
before_action :set_tag, only: [ :index, :new, :create ], if: -> { params[:tag_id].present? }
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(20)
render inertia: 'admin/images/Index', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
if params[:tag_id].present?
# When accessed via /admin/tags/:tag_id/images
@q = @tag.images.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: "admin/tags/images/Index", props: {
tag: @tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ]),
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
}
}
else
# Regular /admin/images 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(20)
render inertia: "admin/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
},
pending_count: Image.pending.count,
approved_count: Image.approved.count,
rejected_count: Image.rejected.count
}
end
end
def show
render inertia: 'admin/images/Show', props: {
image: @image.as_json(include: [:user, :tags], methods: [:file_url]),
render inertia: "admin/images/Show", props: {
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
tags: Tag.all.pluck(:name)
}
end
def new
if params[:tag_id].present?
render inertia: "admin/tags/images/New", props: {
tag: @tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ])
}
else
render inertia: "admin/images/New"
end
end
def create
@image = Current.user.images.new(image_create_params)
if @image.save
# Add the tag if we're creating from a tag context
if params[:tag_id].present? && @tag
@image.tags << @tag unless @image.tags.include?(@tag)
end
if params[:tag_id].present?
redirect_to admin_tag_images_path(@tag), notice: "Image was successfully created and tagged."
else
redirect_to admin_images_path, notice: "Image was successfully created."
end
else
if params[:tag_id].present?
render inertia: "admin/tags/images/New", props: {
tag: @tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ]),
errors: @image.errors
}, status: :unprocessable_entity
else
render inertia: "admin/images/New", props: {
errors: @image.errors
}, status: :unprocessable_entity
end
end
end
def update
if @image.update(image_params)
redirect_to admin_image_path(@image), notice: 'Image was successfully updated.'
redirect_to admin_image_path(@image), notice: "Image was successfully updated."
else
render inertia: 'admin/images/Show', props: {
image: @image.as_json(include: [:user, :tags], methods: [:file_url, :errors]),
render inertia: "admin/images/Show", props: {
image: @image.as_json(include: [ :user, :tags ], methods: [ :file_url, :errors ]),
tags: Tag.all.pluck(:name),
errors: @image.errors
}, status: :unprocessable_entity
......@@ -37,15 +98,15 @@ class Admin::ImagesController < ApplicationController
def destroy
@image.destroy
redirect_to admin_images_path, notice: 'Image was successfully deleted.'
redirect_to admin_images_path, notice: "Image was successfully deleted."
end
def pending
@q = Image.pending.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: 'admin/images/Pending', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
render inertia: "admin/images/Pending", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
......@@ -57,9 +118,9 @@ class Admin::ImagesController < ApplicationController
def approved
@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(20)
render inertia: 'admin/images/Approved', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
render inertia: "admin/images/Approved", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
......@@ -71,9 +132,9 @@ class Admin::ImagesController < ApplicationController
def rejected
@q = Image.rejected.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(20)
render inertia: 'admin/images/Rejected', props: {
images: @images.as_json(include: [:user, :tags], methods: [:file_url]),
render inertia: "admin/images/Rejected", props: {
images: @images.as_json(include: [ :user, :tags ], methods: [ :file_url ]),
filters: params[:q] || {},
total_count: Image.count,
pending_count: Image.pending.count,
......@@ -84,42 +145,50 @@ class Admin::ImagesController < ApplicationController
def approve
@image.approved!
redirect_to admin_image_path(@image), notice: 'Image was successfully approved.'
redirect_to admin_image_path(@image), notice: "Image was successfully approved."
end
def reject
@image.rejected!
redirect_to admin_image_path(@image), notice: 'Image was rejected.'
redirect_to admin_image_path(@image), notice: "Image was rejected."
end
def add_tags
if params[:tags].present?
@image.add_tags(params[:tags].split(','))
redirect_to admin_image_path(@image), notice: 'Tags were successfully added.'
@image.add_tags(params[:tags].split(","))
redirect_to admin_image_path(@image), notice: "Tags were successfully added."
else
redirect_to admin_image_path(@image), alert: 'No tags were provided.'
redirect_to admin_image_path(@image), alert: "No tags were provided."
end
end
def remove_tag
tag = Tag.find(params[:tag_id])
@image.tags.delete(tag)
redirect_to admin_image_path(@image), notice: 'Tag was successfully removed.'
redirect_to admin_image_path(@image), notice: "Tag was successfully removed."
end
private
def set_image
@image = Image.find(params[:id])
end
def set_tag
@tag = Tag.find(params[:tag_id])
end
def image_params
params.require(:image).permit(:title)
end
def image_create_params
params.require(:image).permit(:title, :file)
end
def authorize_admin
unless Current.user&.admin?
redirect_to root_path, alert: 'You are not authorized to access this area.'
redirect_to root_path, alert: "You are not authorized to access this area."
end
end
end
class Admin::TagsController < ApplicationController
before_action :require_admin
def index
@tags = Tag.includes(:images).all
# Group tags by catalog
grouped_tags = @tags.group_by(&:catalog)
# Sort catalogs alphabetically, with nil catalog at the end
sorted_catalogs = grouped_tags.keys.compact.sort
sorted_catalogs << nil if grouped_tags.key?(nil)
# Create the final grouped tags hash with sorted catalogs
@grouped_tags = {}
sorted_catalogs.each do |catalog|
@grouped_tags[catalog] = grouped_tags[catalog]
end
render inertia: 'admin/tags/Index', props: {
tags: @grouped_tags.transform_values do |tags|
tags.map do |tag|
tag.as_json(only: [:id, :name, :catalog], methods: [:images_count])
end
end
}
end
def create
@tag = Tag.new(tag_params)
if @tag.save
redirect_to admin_tags_path, notice: 'Tag was successfully created.'
else
# Return to index with errors
redirect_to admin_tags_path, alert: @tag.errors.full_messages.join(', ')
end
end
def update
@tag = Tag.find(params[:id])
if @tag.update(tag_params)
redirect_to admin_tags_path, notice: 'Tag was successfully updated.'
else
redirect_to admin_tags_path, alert: @tag.errors.full_messages.join(', ')
end
end
def destroy
@tag = Tag.find(params[:id])
if @tag.destroy
redirect_to admin_tags_path, notice: 'Tag was successfully deleted.'
else
redirect_to admin_tags_path, alert: 'Unable to delete tag.'
end
end
private
def tag_params
params.require(:tag).permit(:name, :catalog)
end
def require_admin
unless authenticated?.user&.roles&.include?('admin')
render inertia: 'Forbidden', props: { message: 'You do not have permission to access this page.' }, status: :forbidden
end
end
end
......@@ -13,6 +13,10 @@ module Authentication
end
private
def current_user
authenticated?&.user
end
def authenticated?
resume_session
end
......@@ -31,7 +35,7 @@ module Authentication
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
redirect_to new_sessions_path
end
def after_authentication_url
......
......@@ -6,33 +6,18 @@ class ImagesController < ApplicationController
before_action :authorize_admin, only: [ :approve, :reject, :destroy ]
def index
@q = Image.includes(:user, :tags).ransack(params[:q])
@q = current_user.images.includes(:user, :tags).ransack(params[:q])
@images = @q.result(distinct: true).with_attached_file.order(created_at: :desc).page(params[:page]).per(12)
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
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
def show
......
......@@ -10,7 +10,7 @@ class PasswordsController < ApplicationController
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
redirect_to new_sessions_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
......@@ -18,7 +18,7 @@ class PasswordsController < ApplicationController
def update
if @user.update(params.permit(:password, :password_confirmation))
redirect_to new_session_path, notice: "Password has been reset."
redirect_to new_sessions_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
......
class TagsController < ApplicationController
allow_unauthenticated_access only: [ :index ]
def index
@tags = Tag.includes(:images).all
# Group tags by catalog
grouped_tags = @tags.group_by(&:catalog)
# Sort catalogs alphabetically, with nil catalog at the end
sorted_catalogs = grouped_tags.keys.compact.sort
sorted_catalogs << nil if grouped_tags.key?(nil)
# Create the final grouped tags hash with sorted catalogs
@grouped_tags = {}
sorted_catalogs.each do |catalog|
@grouped_tags[catalog] = grouped_tags[catalog]
end
render inertia: "tags/Index", props: {
tags: @grouped_tags.transform_values do |tags|
tags.map do |tag|
tag.as_json(only: [ :id, :name, :catalog ], methods: [ :images_count ])
end
end
}
end
def new
render inertia: "tags/New"
end
def create
@tag = Tag.new(tag_params)
if @tag.save
redirect_to tags_path, notice: "Tag was successfully created."
else
render inertia: "tags/New", props: {
tag: @tag.as_json,
errors: @tag.errors
}, status: :unprocessable_entity
end
end
def edit
@tag = Tag.find(params[:id])
render inertia: "tags/Edit", props: {
tag: @tag.as_json(only: [ :id, :name, :catalog ])
}
end
def update
@tag = Tag.find(params[:id])
if @tag.update(tag_params)
redirect_to tags_path, notice: "Tag was successfully updated."
else
render inertia: "tags/Edit", props: {
tag: @tag.as_json,
errors: @tag.errors
}, status: :unprocessable_entity
end
end
def destroy
@tag = Tag.find(params[:id])
@tag.destroy
redirect_to tags_path, notice: "Tag was successfully deleted."
end
private
def tag_params
params.require(:tag).permit(:name, :catalog)
end
end
import { useState, useEffect, useRef, useCallback } from 'react'
import { Head, Link, router } from '@inertiajs/react'
import * as Form from '@radix-ui/react-form'
import * as Collapsible from '@radix-ui/react-collapsible'
import { uniqBy } from 'lodash-es'
export default function ComImageIndex({ title, description, path, images, pagination, filters, auth, backLink, actionButton }) {
const [allImages, setAllImages] = useState(images || [])
const [page, setPage] = useState(pagination?.current_page || 1)
const [maxVisiblePage, setMaxVisiblePage] = useState(0)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(pagination?.current_page < pagination?.total_pages)
const [filterCount, setFilterCount] = useState(0)
const [loadedPageMap, setLoadedPageMap] = useState({})
const bottomObserver = useRef()
const [searchParams, setSearchParams] = useState({
title_cont: filters.title_cont || '',
tags_name_cont: filters.tags_name_cont || '',
tags_id_in: filters.tags_id_in || [],
created_at_gteq: filters.created_at_gteq || '',
created_at_lteq: filters.created_at_lteq || '',
})
const [isFilterOpen, setIsFilterOpen] = useState(false)
const handleInputChange = (e) => {
const { name, value } = e.target
setSearchParams({ ...searchParams, [name]: value })
}
const handleSearch = (e) => {
e.preventDefault()
setLoadedPageMap({})
setMaxVisiblePage(0)
setAllImages([])
loadPage(1)
}
const loadPage = async (currentPage) => {
if (loadedPageMap[currentPage]) {
return loadedPageMap[currentPage];
}
try {
setLoading(true)
await router.visit(
path,
{
method: 'get',
data: {
page: currentPage,
q: {
title_cont: searchParams.title_cont,
tags_name_cont: searchParams.tags_name_cont,
tags_id_in: searchParams.tags_id_in,
created_at_gteq: searchParams.created_at_gteq,
created_at_lteq: searchParams.created_at_lteq
}
},
preserveState: true,
preserveScroll: true,
replace: true,
only: ['images', 'pagination'],
onSuccess: (response) => {
// Prepend new images to existing ones
setPage(response.props.pagination.current_page)
setHasMore(maxVisiblePage < response.props.pagination.total_pages)
setFilterCount(Object.values(searchParams).filter(i => Array.isArray(i) ? i.length : !!i).length)
setLoadedPageMap({...loadedPageMap, [response.props.pagination.current_page]: response.props.images})
setLoading(false)
},
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]) {
await loadPage(expectPage)
}
const imagesPage = Math.max(expectPage, maxVisiblePage)
const images = []
for (let i = 1; i <= imagesPage; i++) {
if (loadedPageMap[i]) {
images.push(...loadedPageMap[i])
}
}
setAllImages(
uniqBy(images, (image) => image.id)
)
setMaxVisiblePage(expectPage)
}
useEffect(() => {
if (page >= 1 && !loadedPageMap[1]) {
setVisibleImages(1)
} else {
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])
return (
<>
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
{backLink && (
<Link
href={backLink.url}
className="inline-flex items-center mb-3 text-sm font-medium text-indigo-600 hover:text-indigo-500"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{backLink.label}
</Link>
)}
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
{description}
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${filterCount ? '!bg-indigo-700 !text-white' : ''}`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
{isFilterOpen ? '隐藏过滤' : '显示过滤'}{ filterCount ? `(${filterCount})` : ''}
</button>
{actionButton ? (
actionButton
) : auth && (
<Link
href={`${path}/new`}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
上传新文件
</Link>
)}
</div>
</div>
<div className="px-4 sm:px-6 pb-5">
<Collapsible.Root open={isFilterOpen} onOpenChange={setIsFilterOpen} className="w-full">
<Collapsible.Content className="mb-6 mt-4 overflow-hidden">
<Form.Root
className="space-y-6 border border-gray-200 rounded-md p-4 bg-gray-50"
onSubmit={handleSearch}
>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Form.Field name="title_cont">
<Form.Label className="block text-sm font-medium text-gray-700">
名称
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="title_cont"
value={searchParams.title_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="按名称搜索"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="tags_name_cont">
<Form.Label className="block text-sm font-medium text-gray-700">
标签名称
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="tags_name_cont"
value={searchParams.tags_name_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="按标签名称搜索"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="created_at_gteq">
<Form.Label className="block text-sm font-medium text-gray-700">
开始日期
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="created_at_gteq"
value={searchParams.created_at_gteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="created_at_lteq">
<Form.Label className="block text-sm font-medium text-gray-700">
结束日期
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="created_at_lteq"
value={searchParams.created_at_lteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => {
setSearchParams({
title_cont: '',
tags_name_cont: '',
created_at_gteq: '',
created_at_lteq: '',
})
}}
>
清空
</button>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 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"
>
搜索
</button>
</div>
</Form.Root>
</Collapsible.Content>
</Collapsible.Root>
{allImages.length > 0 ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 mt-6">
{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)]">
加载中...
</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"
/>
<div className="absolute top-2 right-2">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
image.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: image.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{image.status}
</span>
</div>
</div>
<div className="px-4 py-4">
<h3 className="text-lg font-medium text-gray-900 truncate">
{image.title}
</h3>
<div className="mt-2 flex flex-wrap gap-1">
{image.tags.map((tag) => (
<span
key={`tag-${tag.id}`}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))}
</div>
<div className="mt-4 flex justify-between">
<Link
href={`${path}/${image.id}`}
className="text-sm text-indigo-600 hover:text-indigo-900"
>
查看详情
</Link>
<span className="text-sm text-gray-500">
{new Date(image.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
{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"
>
<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">
暂无文件
</h3>
<p className="mt-1 text-sm text-gray-500">
开始上传文件
</p>
<div className="mt-6">
<Link
href={`${path}/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"
>
上传新文件
</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>
)}
</div>
</>
)
}
\ No newline at end of file
import { useState, useMemo } from 'react'
import { Link, useForm } from '@inertiajs/react'
import * as Dialog from '@radix-ui/react-dialog'
import * as Form from '@radix-ui/react-form'
import { TagIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'
export default function ComTagsIndex({
title,
description,
tags,
auth,
isAdmin = false,
path,
errors = {}
}) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [editingTag, setEditingTag] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
// Get all catalog names
const catalogNames = Object.keys(tags);
// Filter tags based on search query
const filteredTags = useMemo(() => {
if (!searchQuery.trim()) return tags;
const query = searchQuery.toLowerCase().trim();
const result = {};
Object.keys(tags).forEach(catalog => {
const filteredCatalogTags = tags[catalog].filter(tag =>
tag.name.toLowerCase().includes(query)
);
if (filteredCatalogTags.length > 0) {
result[catalog] = filteredCatalogTags;
}
});
return result;
}, [tags, searchQuery]);
const { data: createData, setData: setCreateData, post: createTag, processing: createProcessing, reset: resetCreate } = useForm({
name: '',
catalog: ''
})
const { data: editData, setData: setEditData, patch: updateTag, processing: editProcessing, reset: resetEdit } = useForm({
name: '',
catalog: ''
})
const handleCreateSubmit = (e) => {
e.preventDefault()
createTag(`${path}`, {
onSuccess: () => {
setIsCreateModalOpen(false)
resetCreate()
},
})
}
const handleEditSubmit = (e) => {
e.preventDefault()
updateTag(`${path}/${editingTag.id}`, {
onSuccess: () => {
setIsEditModalOpen(false)
setEditingTag(null)
resetEdit()
},
})
}
const openEditModal = (tag) => {
setEditingTag(tag)
setEditData({
name: tag.name,
catalog: tag.catalog || ''
})
setIsEditModalOpen(true)
}
return (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
{description}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{/* Search input */}
<div className="relative flex-grow sm:max-w-xs">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索标签..."
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
{auth && isAdmin && (
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
创建新标签
</button>
)}
</div>
</div>
<div className="border-t border-gray-200">
{Object.keys(filteredTags).length > 0 ? (
<div className="px-4 py-5 space-y-8">
{Object.keys(filteredTags).map((catalog) => (
<div key={catalog} className="pb-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">
{catalog || '未分类'} <span className="text-sm text-gray-500">({filteredTags[catalog].length})</span>
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{filteredTags[catalog].map((tag) => (
<div
key={tag.id}
className="bg-gray-50 rounded-lg p-4 py-2 hover:bg-gray-100 transition-colors flex flex-col"
>
<div className="flex justify-between items-start">
<div className="flex items-center">
<div className="flex-shrink-0 mr-2">
<TagIcon className="h-5 w-5 text-indigo-500" />
</div>
<div>
<span className="text-sm font-medium text-gray-900">{tag.name}</span>
</div>
</div>
<Link
href={`${path}/${tag.id}/images`}
className="text-xs font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded-full transition-colors"
>
{tag.images_count}
</Link>
</div>
{isAdmin && <div className="!mt-2 flex space-x-2 justify-end mt-auto pt-2 border-t border-gray-200">
<button
onClick={() => openEditModal(tag)}
className="text-xs text-indigo-600 hover:text-indigo-900"
>
编辑
</button>
<Link
href={`${path}/${tag.id}`}
method="delete"
as="button"
data={{ confirm: "确定要删除这个标签吗?这将从所有关联的文件中移除该标签。" }}
className="text-xs text-red-600 hover:text-red-900"
>
删除
</Link>
</div>}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<TagIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">
{searchQuery ? '没有匹配的标签' : '没有标签'}
</h3>
<p className="mt-1 text-sm text-gray-500">
{searchQuery
? '尝试使用不同的搜索词。'
: isAdmin ? '创建一个新标签开始使用。' : '目前没有可用的标签。'}
</p>
{auth && isAdmin && !searchQuery && (
<div className="mt-6">
<button
onClick={() => setIsCreateModalOpen(true)}
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"
>
创建新标签
</button>
</div>
)}
</div>
)}
</div>
{/* Create Tag Modal */}
{isAdmin && (
<Dialog.Root open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/30" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
创建新标签
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
添加一个新标签用于文件分类。
</Dialog.Description>
<Form.Root className="mt-4" onSubmit={handleCreateSubmit}>
<Form.Field name="name" className="mb-4">
<div className="flex items-baseline justify-between">
<Form.Label className="text-sm font-medium text-gray-700">
标签名称
</Form.Label>
<Form.Message
className="text-xs text-red-500"
match="valueMissing"
>
请输入标签名称
</Form.Message>
{errors.name && (
<p className="text-xs text-red-500">{errors.name}</p>
)}
</div>
<Form.Control asChild>
<input
type="text"
value={createData.name}
onChange={(e) => setCreateData({ ...createData, name: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</Form.Control>
</Form.Field>
<Form.Field name="catalog" className="mb-4">
<div className="flex items-baseline justify-between">
<Form.Label className="text-sm font-medium text-gray-700">
分类(可选)
</Form.Label>
{errors.catalog && (
<p className="text-xs text-red-500">{errors.catalog}</p>
)}
</div>
<Form.Control asChild>
<input
type="text"
value={createData.catalog}
onChange={(e) => setCreateData({ ...createData, catalog: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</Form.Control>
</Form.Field>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 space-y-2">
<button
type="button"
onClick={() => setIsCreateModalOpen(false)}
className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm"
>
取消
</button>
<button
type="submit"
disabled={createProcessing}
className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm disabled:opacity-50"
>
创建
</button>
</div>
</Form.Root>
<Dialog.Close asChild>
<button
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)}
{/* Edit Tag Modal */}
{isAdmin && (
<Dialog.Root open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/30" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
编辑标签
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
更新标签信息。
</Dialog.Description>
<Form.Root className="mt-4" onSubmit={handleEditSubmit}>
<Form.Field name="name" className="mb-4">
<div className="flex items-baseline justify-between">
<Form.Label className="text-sm font-medium text-gray-700">
标签名称
</Form.Label>
<Form.Message
className="text-xs text-red-500"
match="valueMissing"
>
请输入标签名称
</Form.Message>
{errors.name && (
<p className="text-xs text-red-500">{errors.name}</p>
)}
</div>
<Form.Control asChild>
<input
type="text"
value={editData.name}
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
required
/>
</Form.Control>
</Form.Field>
<Form.Field name="catalog" className="mb-4">
<div className="flex items-baseline justify-between">
<Form.Label className="text-sm font-medium text-gray-700">
分类(可选)
</Form.Label>
{errors.catalog && (
<p className="text-xs text-red-500">{errors.catalog}</p>
)}
</div>
<Form.Control asChild>
<input
type="text"
value={editData.catalog}
onChange={(e) => setEditData({ ...editData, catalog: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</Form.Control>
</Form.Field>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 space-y-2">
<button
type="button"
onClick={() => {
setIsEditModalOpen(false)
setEditingTag(null)
}}
className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm"
>
取消
</button>
<button
type="submit"
disabled={editProcessing}
className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:text-sm disabled:opacity-50"
>
更新
</button>
</div>
</Form.Root>
<Dialog.Close asChild>
<button
className="absolute top-4 right-4 text-gray-400 hover:text-gray-500"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)}
</div>
)
}
......@@ -6,7 +6,7 @@ export default function Layout({ children, user, title }) {
const [isMenuOpen, setIsMenuOpen] = useState(false)
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
......@@ -218,7 +218,7 @@ export default function Layout({ children, user, title }) {
</div>
</header>
<main>
<main className='flex-grow'>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">{children}</div>
</main>
......
import { useState } from 'react'
import { Head, Link } from '@inertiajs/react'
import { Head } 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 AdminImagesIndex({ images, filters, auth }) {
const [searchParams, setSearchParams] = useState({
title_cont: filters.title_cont || '',
user_name_cont: filters.user_name_cont || '',
status_eq: filters.status_eq || '',
tags_name_cont: filters.tags_name_cont || '',
created_at_gteq: filters.created_at_gteq || '',
created_at_lteq: filters.created_at_lteq || '',
})
const handleInputChange = (e) => {
const { name, value } = e.target
setSearchParams({ ...searchParams, [name]: value })
}
import ComImageIndex from '../../../components/images/ComImageIndex'
export default function AdminImagesIndex({ auth, images, pagination, filters }) {
return (
<Layout user={auth.user}>
<Head title="Admin - Manage 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">
<div>
<h1 className="text-2xl font-bold text-gray-900">Manage Images</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Review and manage all user-uploaded images
</p>
</div>
</div>
<Tabs.Root defaultValue="all" className="px-4 sm:px-6 pb-5">
<Tabs.List className="flex space-x-4 border-b border-gray-200 mb-4">
<Tabs.Trigger
value="all"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
All Images
</Tabs.Trigger>
<Tabs.Trigger
value="pending"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Pending Review
</Tabs.Trigger>
<Tabs.Trigger
value="approved"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Approved
</Tabs.Trigger>
<Tabs.Trigger
value="rejected"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Rejected
</Tabs.Trigger>
<Tabs.Trigger
value="search"
className="px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 data-[state=active]:text-indigo-600"
>
Advanced Search
</Tabs.Trigger>
</Tabs.List>
{['all', 'pending', 'approved', 'rejected'].map((tab) => (
<Tabs.Content key={tab} value={tab} className="focus:outline-none">
{images
.filter(
(image) => tab === 'all' || image.status === tab
)
.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Image
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Title
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Uploader
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Status
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Upload Date
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{images
.filter(
(image) => tab === 'all' || image.status === tab
)
.map((image) => (
<tr key={image.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-16 w-16 relative">
<img
src={image.file_url}
alt={image.title}
className="h-16 w-16 object-cover rounded"
/>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{image.title}
</div>
<div className="text-sm text-gray-500 mt-1 flex flex-wrap gap-1">
{image.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{image.user.name}
</div>
<div className="text-sm text-gray-500">
{image.user.email_address}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
image.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: image.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{image.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(image.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex space-x-2">
<Link
href={`/images/${image.id}`}
className="text-indigo-600 hover:text-indigo-900"
>
View
</Link>
{image.status === 'pending' && (
<>
<Link
href={`/admin/images/${image.id}/approve`}
method="patch"
as="button"
className="text-green-600 hover:text-green-900"
>
Approve
</Link>
<Link
href={`/admin/images/${image.id}/reject`}
method="patch"
as="button"
className="text-red-600 hover:text-red-900"
>
Reject
</Link>
</>
)}
<Link
href={`/admin/images/${image.id}`}
method="delete"
as="button"
className="text-gray-600 hover:text-gray-900"
onClick={(e) => {
if (!confirm('Are you sure you want to delete this image?')) {
e.preventDefault()
}
}}
>
Delete
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</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 found
</h3>
<p className="mt-1 text-sm text-gray-500">
{tab === 'pending'
? 'There are no pending images to review.'
: tab === 'approved'
? 'There are no approved images.'
: tab === 'rejected'
? 'There are no rejected images.'
: 'There are no images in the system.'}
</p>
</div>
)}
</Tabs.Content>
))}
<Tabs.Content value="search" className="focus:outline-none">
<Form.Root
className="space-y-6"
action="/admin/images"
method="get"
>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Form.Field name="q[title_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Title
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[title_cont]"
value={searchParams.title_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by title"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[user_name_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Uploader
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[user_name_cont]"
value={searchParams.user_name_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by uploader name"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-2">
<Form.Field name="q[status_eq]">
<Form.Label className="block text-sm font-medium text-gray-700">
Status
</Form.Label>
<Form.Control asChild>
<select
name="q[status_eq]"
value={searchParams.status_eq}
onChange={handleInputChange}
className="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Any status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-4">
<Form.Field name="q[tags_name_cont]">
<Form.Label className="block text-sm font-medium text-gray-700">
Tags
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="q[tags_name_cont]"
value={searchParams.tags_name_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Search by tag"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[created_at_gteq]">
<Form.Label className="block text-sm font-medium text-gray-700">
From Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_gteq]"
value={searchParams.created_at_gteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="q[created_at_lteq]">
<Form.Label className="block text-sm font-medium text-gray-700">
To Date
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="q[created_at_lteq]"
value={searchParams.created_at_lteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
className="inline-flex justify-center py-2 px-4 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"
>
Search
</button>
</div>
</Form.Root>
</Tabs.Content>
</Tabs.Root>
</div>
<Head title="文件管理" />
<ComImageIndex
auth={auth}
title="文件管理"
description="审批并管理用户上传的文件"
path="/admin/images"
images={images}
pagination={pagination}
filters={filters}
/>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import { Head } from '@inertiajs/react'
import Layout from '../../Layout'
import * as Dialog from '@radix-ui/react-dialog'
import * as Form from '@radix-ui/react-form'
import ComTagsIndex from '../../../components/tags/ComTagsIndex'
export default function AdminTagsIndex({ tags, auth, errors = {} }) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [editingTag, setEditingTag] = useState(null)
const { data: createData, setData: setCreateData, post: createTag, processing: createProcessing, reset: resetCreate } = useForm({
name: '',
})
const { data: editData, setData: setEditData, patch: updateTag, processing: editProcessing, reset: resetEdit } = useForm({
name: '',
})
const handleCreateSubmit = (e) => {
e.preventDefault()
createTag('/admin/tags', {
onSuccess: () => {
setIsCreateModalOpen(false)
resetCreate()
},
})
}
const handleEditSubmit = (e) => {
e.preventDefault()
updateTag(`/admin/tags/${editingTag.id}`, {
onSuccess: () => {
setIsEditModalOpen(false)
setEditingTag(null)
resetEdit()
},
})
}
const openEditModal = (tag) => {
setEditingTag(tag)
setEditData({ name: tag.name })
setIsEditModalOpen(true)
}
return (
<Layout user={auth.user}>
<Head title="Admin - Manage Tags" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Manage Tags</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Create, edit, and delete tags for image categorization
</p>
</div>
<button
onClick={() => setIsCreateModalOpen(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create New Tag
</button>
</div>
<div className="border-t border-gray-200">
{tags.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Image Count
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Created At
</th>
<th
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tags.map((tag) => (
<tr key={tag.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{tag.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{tag.images_count} images
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(tag.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex space-x-2 justify-end">
<button
onClick={() => openEditModal(tag)}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</button>
<a
href={`/admin/tags/${tag.id}`}
data-method="delete"
data-confirm="Are you sure you want to delete this tag? This will remove the tag from all associated images."
className="text-red-600 hover:text-red-900"
>
Delete
</a>
</div>
</td>
</tr>
))}
</tbody>
</table>
</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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No tags
</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating a new tag.
</p>
<div className="mt-6">
<button
onClick={() => setIsCreateModalOpen(true)}
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"
>
Create New Tag
</button>
</div>
</div>
)}
</div>
</div>
{/* Create Tag Modal */}
<Dialog.Root open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
Create New Tag
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
Create a new tag for categorizing images
</Dialog.Description>
<Form.Root className="mt-4" onSubmit={handleCreateSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tag Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={createData.name}
onChange={(e) => setCreateData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tag name"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<Form.Submit asChild>
<button
type="submit"
disabled={createProcessing}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
{createProcessing ? 'Creating...' : 'Create'}
</button>
</Form.Submit>
<Dialog.Close asChild>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
</button>
</Dialog.Close>
</div>
</Form.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/* Edit Tag Modal */}
<Dialog.Root open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<Dialog.Title className="text-lg font-medium text-gray-900">
Edit Tag
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-gray-500">
Update the tag name
</Dialog.Description>
<Form.Root className="mt-4" onSubmit={handleEditSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tag Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={editData.name}
onChange={(e) => setEditData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tag name"
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<Form.Submit asChild>
<button
type="submit"
disabled={editProcessing}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
{editProcessing ? 'Saving...' : 'Save Changes'}
</button>
</Form.Submit>
<Dialog.Close asChild>
<button
type="button"
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
</button>
</Dialog.Close>
</div>
</Form.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Head title="标签管理" />
<ComTagsIndex
title="标签管理"
description="创建、编辑和删除文件分类标签"
tags={tags}
auth={auth}
isAdmin={true}
path="/admin/tags"
errors={errors}
/>
</Layout>
)
}
import { Head, Link } from '@inertiajs/react'
import Layout from '../../../Layout'
import ComImageIndex from '../../../../components/images/ComImageIndex'
export default function TagsImagesIndex({ auth, tag, images, pagination, filters }) {
return (
<Layout user={auth.user}>
<Head title={`${tag.name} - 标签文件`} />
<ComImageIndex
auth={auth}
title={tag.name}
description={`查看关联文件`}
path={`/admin/tags/${tag.id}/images`}
images={images}
pagination={pagination}
filters={filters}
backLink={{
url: '/admin/tags',
label: '返回标签列表'
}}
actionButton={(
<Link
href={`/admin/tags/${tag.id}/images/new`}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
添加新文件到此标签
</Link>
)}
/>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm, router, Link } from '@inertiajs/react'
import Layout from '../../../Layout'
import * as Form from '@radix-ui/react-form'
export default function New({ auth, tag, errors = {} }) {
const [preview, setPreview] = useState(null)
const { data, setData, processing } = useForm({
title: '',
file: null,
})
const handleSubmit = (e) => {
e.preventDefault()
router.post(`/admin/tags/${tag.id}/images`, { image: data })
}
const handleFileChange = (e) => {
const file = e.target.files[0]
setData('file', file)
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setPreview(reader.result)
}
reader.readAsDataURL(file)
} else {
setPreview(null)
}
}
return (
<Layout user={auth.user}>
<Head title={`添加文件到标签: ${tag.name}`} />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<div className="flex items-center mb-4">
<Link
href={`/admin/tags/${tag.id}/images`}
className="inline-flex items-center text-sm font-medium text-indigo-600 hover:text-indigo-500"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
返回标签文件列表
</Link>
</div>
<h1 className="text-2xl font-bold text-gray-900">添加文件到标签: {tag.name}</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
上传新文件并自动添加到标签 "{tag.name}"
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="title" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
标题
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="输入标题"
/>
</Form.Control>
{errors.title && (
<Form.Message className="text-sm text-red-600">
{errors.title}
</Form.Message>
)}
</Form.Field>
<Form.Field name="file" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
文件
</Form.Label>
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div className="space-y-1 text-center">
{preview ? (
<div className="mb-4">
<img
src={preview}
alt="Preview"
className="mx-auto h-64 object-contain"
/>
</div>
) : (
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
<div className="flex text-sm text-gray-600">
<label
htmlFor="file-upload"
className="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
>
<span>上传文件</span>
<Form.Control asChild>
<input
id="file-upload"
name="file"
type="file"
className="sr-only"
onChange={handleFileChange}
/>
</Form.Control>
</label>
<p className="pl-1">或拖拽上传</p>
</div>
<p className="text-xs text-gray-500">
{/* PNG, JPG, GIF up to 10MB */}
</p>
</div>
</div>
{errors.file && (
<Form.Message className="text-sm text-red-600">
{errors.file}
</Form.Message>
)}
</Form.Field>
<div className="flex justify-end">
<Form.Submit asChild>
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 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 disabled:opacity-50"
>
{processing ? '上传中...' : '上传并添加到标签'}
</button>
</Form.Submit>
</div>
</Form.Root>
</div>
</div>
</Layout>
)
}
......@@ -45,7 +45,7 @@ export default function Edit({ image, tags, auth, errors = {} }) {
value={data.title}
onChange={(e) => setData('title', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter image title"
placeholder="输入标题"
/>
</Form.Control>
{errors.title && (
......
import { useState, useEffect, useRef, useCallback } from 'react'
import { Head, Link, router } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
import * as Collapsible from '@radix-ui/react-collapsible'
import { uniqBy } from 'lodash-es'
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)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(pagination?.current_page < pagination?.total_pages)
const [filterCount, setFilterCount] = useState(0)
const [loadedPageMap, setLoadedPageMap] = useState({})
const bottomObserver = useRef()
const [searchParams, setSearchParams] = useState({
title_cont: filters.title_cont || '',
tags_name_cont: filters.tags_name_cont || '',
created_at_gteq: filters.created_at_gteq || '',
created_at_lteq: filters.created_at_lteq || '',
})
const [isFilterOpen, setIsFilterOpen] = useState(false)
const handleInputChange = (e) => {
const { name, value } = e.target
setSearchParams({ ...searchParams, [name]: value })
}
const handleSearch = (e) => {
e.preventDefault()
setLoadedPageMap({})
setMaxVisiblePage(0)
setAllImages([])
loadPage(1)
}
const loadPage = async (currentPage) => {
if (loadedPageMap[currentPage]) {
return loadedPageMap[currentPage];
}
try {
setLoading(true)
await router.visit(
'/images',
{
method: 'get',
data: {
page: currentPage,
q: {
title_cont: searchParams.title_cont,
tags_name_cont: searchParams.tags_name_cont,
created_at_gteq: searchParams.created_at_gteq,
created_at_lteq: searchParams.created_at_lteq
}
},
preserveState: true,
preserveScroll: true,
replace: true,
only: ['images', 'pagination'],
onSuccess: (response) => {
// Prepend new images to existing ones
setPage(response.props.pagination.current_page)
setHasMore(maxVisiblePage < response.props.pagination.total_pages)
setFilterCount(Object.values(searchParams).filter(i => i).length)
setLoadedPageMap({...loadedPageMap, [response.props.pagination.current_page]: response.props.images})
setLoading(false)
},
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]) {
await loadPage(expectPage)
}
setAllImages(uniqBy([...allImages, ...(loadedPageMap[expectPage] || [])], (image) => image.id))
setMaxVisiblePage(expectPage)
}
useEffect(() => {
if (page >= 1 && !loadedPageMap[1]) {
setVisibleImages(1)
} else {
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])
import ComImageIndex from '../../components/images/ComImageIndex'
import { Head } from '@inertiajs/react'
export default function Index({ auth, images, pagination, filters }) {
return (
<Layout user={auth}>
<Head title="我的文件" />
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">我的文件</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
管理已上传的文件
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => setIsFilterOpen(!isFilterOpen)}
className={`inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${filterCount ? '!bg-indigo-700 !text-white' : ''}`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
{isFilterOpen ? '隐藏过滤' : '显示过滤'}{ filterCount ? `(${Object.values(searchParams).filter(i => i).length})` : ''}
</button>
{auth && (
<Link
href="/images/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
上传新文件
</Link>
)}
</div>
</div>
<div className="px-4 sm:px-6 pb-5">
<Collapsible.Root open={isFilterOpen} onOpenChange={setIsFilterOpen} className="w-full">
<Collapsible.Content className="mb-6 mt-4 overflow-hidden">
<Form.Root
className="space-y-6 border border-gray-200 rounded-md p-4 bg-gray-50"
onSubmit={handleSearch}
>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<Form.Field name="title_cont">
<Form.Label className="block text-sm font-medium text-gray-700">
名称
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="title_cont"
value={searchParams.title_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="按名称搜索"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="tags_name_cont">
<Form.Label className="block text-sm font-medium text-gray-700">
标签
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="tags_name_cont"
value={searchParams.tags_name_cont}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="按标签搜索"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="created_at_gteq">
<Form.Label className="block text-sm font-medium text-gray-700">
开始日期
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="created_at_gteq"
value={searchParams.created_at_gteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
<div className="sm:col-span-3">
<Form.Field name="created_at_lteq">
<Form.Label className="block text-sm font-medium text-gray-700">
结束日期
</Form.Label>
<Form.Control asChild>
<input
type="date"
name="created_at_lteq"
value={searchParams.created_at_lteq}
onChange={handleInputChange}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
/>
</Form.Control>
</Form.Field>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => {
setSearchParams({
title_cont: '',
tags_name_cont: '',
created_at_gteq: '',
created_at_lteq: '',
})
}}
>
清空
</button>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 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"
>
搜索
</button>
</div>
</Form.Root>
</Collapsible.Content>
</Collapsible.Root>
{allImages.length > 0 ? (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 mt-6">
{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)]">
加载中...
</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"
/>
<div className="absolute top-2 right-2">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
image.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: image.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{image.status}
</span>
</div>
</div>
<div className="px-4 py-4">
<h3 className="text-lg font-medium text-gray-900 truncate">
{image.title}
</h3>
<div className="mt-2 flex flex-wrap gap-1">
{image.tags.map((tag) => (
<span
key={`tag-${tag.id}`}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800"
>
{tag.name}
</span>
))}
</div>
<div className="mt-4 flex justify-between">
<Link
href={`/images/${image.id}`}
className="text-sm text-indigo-600 hover:text-indigo-900"
>
View details
</Link>
<span className="text-sm text-gray-500">
{new Date(image.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
{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"
>
<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"
>
上传新文件
</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>
)}
</div>
</div>
<ComImageIndex
auth={auth}
title="我的文件"
description="管理已上传的文件"
path="/images"
images={images}
pagination={pagination}
filters={filters}
/>
</Layout>
)
}
......@@ -45,7 +45,7 @@ export default function New({ auth, errors = {} }) {
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="title" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Title
标题
</Form.Label>
<Form.Control asChild>
<input
......@@ -54,7 +54,7 @@ export default function New({ auth, errors = {} }) {
value={data.title}
onChange={(e) => setData('title', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter image title"
placeholder="输入标题"
/>
</Form.Control>
{errors.title && (
......@@ -66,7 +66,7 @@ export default function New({ auth, errors = {} }) {
<Form.Field name="file" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Image File
文件
</Form.Label>
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div className="space-y-1 text-center">
......@@ -99,22 +99,21 @@ export default function New({ auth, errors = {} }) {
htmlFor="file-upload"
className="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
>
<span>Upload a file</span>
<span>上传文件</span>
<Form.Control asChild>
<input
id="file-upload"
name="file"
type="file"
className="sr-only"
accept="image/*"
onChange={handleFileChange}
/>
</Form.Control>
</label>
<p className="pl-1">or drag and drop</p>
<p className="pl-1">或拖拽上传</p>
</div>
<p className="text-xs text-gray-500">
PNG, JPG, GIF up to 10MB
{/* PNG, JPG, GIF up to 10MB */}
</p>
</div>
</div>
......@@ -125,9 +124,9 @@ export default function New({ auth, errors = {} }) {
)}
</Form.Field>
<Form.Field name="tags" className="space-y-2">
{/* <Form.Field name="tags" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tags
标签
</Form.Label>
<Form.Control asChild>
<input
......@@ -142,7 +141,7 @@ export default function New({ auth, errors = {} }) {
<p className="text-xs text-gray-500">
Enter tags separated by commas
</p>
</Form.Field>
</Form.Field> */}
<div className="flex justify-end">
<Form.Submit asChild>
......@@ -151,7 +150,7 @@ export default function New({ auth, errors = {} }) {
disabled={processing}
className="inline-flex justify-center py-2 px-4 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 disabled:opacity-50"
>
{processing ? 'Uploading...' : 'Upload Image'}
{processing ? '上传中...' : '上传文件'}
</button>
</Form.Submit>
</div>
......
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
export default function TagsEdit({ tag, auth, errors = {} }) {
const { data, setData, patch, processing } = useForm({
name: tag.name,
catalog: tag.catalog || '',
})
const handleSubmit = (e) => {
e.preventDefault()
patch(`/tags/${tag.id}`)
}
return (
<Layout user={auth.user}>
<Head title="Edit Tag" />
<div className="max-w-2xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-lg font-medium leading-6 text-gray-900">Edit Tag</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Update tag information
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:p-6">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tag Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tag name"
required
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<Form.Field name="catalog" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Catalog (Optional)
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="catalog"
value={data.catalog}
onChange={(e) => setData('catalog', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter catalog name (e.g. 'Nature', 'Technology')"
/>
</Form.Control>
<p className="text-xs text-gray-500">
Tags are grouped by catalog. Leave empty for uncategorized tags.
</p>
{errors.catalog && (
<Form.Message className="text-sm text-red-600">
{errors.catalog}
</Form.Message>
)}
</Form.Field>
<div className="flex justify-end">
<a
href="/tags"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
>
Cancel
</a>
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 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"
>
{processing ? 'Updating...' : 'Update Tag'}
</button>
</div>
</Form.Root>
</div>
</div>
</div>
</Layout>
)
}
import { Head } from '@inertiajs/react'
import Layout from '../Layout'
import ComTagsIndex from '../../components/tags/ComTagsIndex'
export default function TagsIndex({ tags, auth }) {
return (
<Layout user={auth.user}>
<Head title="Tags" />
<ComTagsIndex
title="Tags"
description="标签管理"
tags={tags}
auth={auth}
isAdmin={false}
path="/tags"
/>
</Layout>
)
}
import { useState } from 'react'
import { Head, useForm } from '@inertiajs/react'
import Layout from '../Layout'
import * as Form from '@radix-ui/react-form'
export default function TagsNew({ auth, errors = {} }) {
const { data, setData, post, processing } = useForm({
name: '',
catalog: '',
})
const handleSubmit = (e) => {
e.preventDefault()
post('/tags')
}
return (
<Layout user={auth.user}>
<Head title="Create New Tag" />
<div className="max-w-2xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h1 className="text-lg font-medium leading-6 text-gray-900">Create New Tag</h1>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Create a new tag for categorizing images
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:p-6">
<Form.Root className="space-y-6" onSubmit={handleSubmit}>
<Form.Field name="name" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Tag Name
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter tag name"
required
/>
</Form.Control>
{errors.name && (
<Form.Message className="text-sm text-red-600">
{errors.name}
</Form.Message>
)}
</Form.Field>
<Form.Field name="catalog" className="space-y-2">
<Form.Label className="block text-sm font-medium text-gray-700">
Catalog (Optional)
</Form.Label>
<Form.Control asChild>
<input
type="text"
name="catalog"
value={data.catalog}
onChange={(e) => setData('catalog', e.target.value)}
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
placeholder="Enter catalog name (e.g. 'Nature', 'Technology')"
/>
</Form.Control>
<p className="text-xs text-gray-500">
Tags are grouped by catalog. Leave empty for uncategorized tags.
</p>
{errors.catalog && (
<Form.Message className="text-sm text-red-600">
{errors.catalog}
</Form.Message>
)}
</Form.Field>
<div className="flex justify-end">
<a
href="/tags"
className="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
>
Cancel
</a>
<button
type="submit"
disabled={processing}
className="inline-flex justify-center py-2 px-4 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"
>
{processing ? 'Creating...' : 'Create Tag'}
</button>
</div>
</Form.Root>
</div>
</div>
</div>
</Layout>
)
}
class Tag < ApplicationRecord
has_many :image_tags, dependent: :destroy
has_many :images, through: :image_tags
validates :name, presence: true, uniqueness: true
before_save :downcase_name
def self.ransackable_attributes(auth_object = nil)
["created_at", "id", "id_value", "status", "name", "updated_at", "user_id"]
[ "created_at", "id", "id_value", "status", "name", "catalog", "updated_at", "user_id" ]
end
# Method to get the count of associated images
def images_count
images.count
end
private
def downcase_name
self.name = name.downcase if name.present?
end
......
Rails.application.routes.draw do
get "tags/index", to: "tags#index"
get "tags/new", to: "tags#new"
get "tags/create", to: "tags#create"
get "tags/edit", to: "tags#edit"
get "tags/update", to: "tags#update"
get "tags/destroy", to: "tags#destroy"
get "images/index", to: "images#index"
get "images/show", to: "images#show"
get "images/new", to: "images#new"
get "images/create", to: "images#create"
get "images/edit", to: "images#edit"
get "images/update", to: "images#update"
get "images/destroy", to: "images#destroy"
get "images/search", to: "images#search"
get "sessions/index", to: "sessions#index"
get "sessions/show", to: "sessions#show"
get "session/new", to: "sessions#new"
get "sessions/security", to: "sessions#security"
# get "tags/index", to: "tags#index"
# get "tags/new", to: "tags#new"
# get "tags/create", to: "tags#create"
# get "tags/edit", to: "tags#edit"
# get "tags/update", to: "tags#update"
# get "tags/destroy", to: "tags#destroy"
# get "images/index", to: "images#index"
# get "images/show", to: "images#show"
# get "images/new", to: "images#new"
# get "images/create", to: "images#create"
# get "images/edit", to: "images#edit"
# get "images/update", to: "images#update"
# get "images/destroy", to: "images#destroy"
# get "images/search", to: "images#search"
# get "sessions/index", to: "sessions#index"
# get "sessions/show", to: "sessions#show"
# get "session/new", to: "sessions#new"
# get "sessions/security", to: "sessions#security"
# Authentication routes
resource :session
......@@ -49,23 +49,23 @@ Rails.application.routes.draw do
# Admin namespace for admin-only actions
namespace :admin do
get "users/index", to: "users#index"
get "users/show", to: "users#show"
get "users/edit", to: "users#edit"
get "users/update", to: "users#update"
get "users/destroy", to: "users#destroy"
get "images/index", to: "images#index"
get "images/show", to: "images#show"
get "images/update", to: "images#update"
get "images/destroy", to: "images#destroy"
get "images/pending", to: "images#pending"
get "images/approved", to: "images#approved"
get "images/rejected", to: "images#rejected"
get "images/approve", to: "images#approve"
get "images/reject", to: "images#reject"
get "images/add_tags", to: "images#add_tags"
get "images/remove_tag", to: "images#remove_tag"
get "dashboard/index", to: "dashboard#index"
# get "users/index", to: "users#index"
# get "users/show", to: "users#show"
# get "users/edit", to: "users#edit"
# get "users/update", to: "users#update"
# get "users/destroy", to: "users#destroy"
# get "images/index", to: "images#index"
# get "images/show", to: "images#show"
# get "images/update", to: "images#update"
# get "images/destroy", to: "images#destroy"
# get "images/pending", to: "images#pending"
# get "images/approved", to: "images#approved"
# get "images/rejected", to: "images#rejected"
# get "images/approve", to: "images#approve"
# get "images/reject", to: "images#reject"
# get "images/add_tags", to: "images#add_tags"
# get "images/remove_tag", to: "images#remove_tag"
# get "dashboard/index", to: "dashboard#index"
resources :images, only: [ :index, :show, :update, :destroy ] do
member do
patch :approve
......@@ -80,7 +80,9 @@ Rails.application.routes.draw do
end
end
resources :users, only: [ :index, :show, :edit, :update, :destroy ]
resources :tags, only: [ :index, :create, :destroy ]
resources :tags, only: [ :index, :create, :update, :destroy ] do
resources :images, only: [ :index, :new, :create ]
end
root to: "dashboard#index"
end
......
class AddCatalogToTags < ActiveRecord::Migration[8.0]
def change
add_column :tags, :catalog, :string
end
end
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_08_073302) do
ActiveRecord::Schema[8.0].define(version: 2025_03_09_035054) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
......@@ -70,6 +70,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_08_073302) do
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "catalog"
end
create_table "users", force: :cascade do |t|
......
......@@ -5,6 +5,7 @@
"packages": {
"": {
"dependencies": {
"@heroicons/react": "^2.2.0",
"@inertiajs/react": "^2.0.5",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
......@@ -717,6 +718,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@inertiajs/core": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.0.5.tgz",
......
......@@ -6,6 +6,7 @@
"vite-plugin-ruby": "^5.1.1"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@inertiajs/react": "^2.0.5",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
......
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