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
......@@ -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 { 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 && (
......
......@@ -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