Commit b89d601f by Ivan

feat: 过滤搜索

parent 68348823
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { Head, Link, router } from '@inertiajs/react' import { Head, Link, router } from '@inertiajs/react'
import Layout from '../Layout' import Layout from '../Layout'
import * as Tabs from '@radix-ui/react-tabs'
import * as Form from '@radix-ui/react-form' 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 }) { export default function Index({ images, filters, pagination, auth }) {
const [allImages, setAllImages] = useState(images || []) const [allImages, setAllImages] = useState(images || [])
...@@ -19,51 +20,23 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -19,51 +20,23 @@ export default function Index({ images, filters, pagination, auth }) {
created_at_gteq: filters.created_at_gteq || '', created_at_gteq: filters.created_at_gteq || '',
created_at_lteq: filters.created_at_lteq || '', created_at_lteq: filters.created_at_lteq || '',
}) })
const [isFilterOpen, setIsFilterOpen] = useState(false)
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target const { name, value } = e.target
setSearchParams({ ...searchParams, [name]: value }) setSearchParams({ ...searchParams, [name]: value })
} }
// const loadMoreImages = useCallback(() => { const handleSearch = (e) => {
// if (loading || !hasMore) return e.preventDefault()
setLoadedPageMap({})
// try { maxVisiblePage(0)
// const nextPage = page + 1 setAllImages([])
// if (loadedPageMap[nextPage]) return
// setLoading(true) loadPage(1)
// // Use Inertia's router.visit with replace option to avoid changing URL }
// router.visit(
// '/images',
// {
// method: 'get',
// data: { page: nextPage },
// preserveState: true,
// preserveScroll: true,
// replace: true, // Replace current URL instead of adding to history
// only: ['images', 'pagination'],
// onSuccess: (response) => {
// // Append new images to existing ones
// setPage(response.props.pagination.current_page)
// setHasMore(response.props.pagination.current_page < response.props.pagination.total_pages)
// setLoading(false)
// setLoadedPageMap({ ...loadedPageMap, [response.props.pagination.current_page]: response.props.images })
// },
// onError: () => {
// console.error('Error loading more images')
// setLoading(false)
// }
// }
// )
// } catch (error) {
// console.error('Error loading more images:', error)
// setLoading(false)
// }
// }, [page, loading, hasMore])
const loadPage = ((currentPage) => { const loadPage = async (currentPage) => {
if (loadedPageMap[currentPage]) { if (loadedPageMap[currentPage]) {
return loadedPageMap[currentPage]; return loadedPageMap[currentPage];
} }
...@@ -71,11 +44,19 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -71,11 +44,19 @@ export default function Index({ images, filters, pagination, auth }) {
try { try {
setLoading(true) setLoading(true)
router.visit( await router.visit(
'/images', '/images',
{ {
method: 'get', method: 'get',
data: { page: currentPage }, 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, preserveState: true,
preserveScroll: true, preserveScroll: true,
replace: true, replace: true,
...@@ -83,9 +64,11 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -83,9 +64,11 @@ export default function Index({ images, filters, pagination, auth }) {
onSuccess: (response) => { onSuccess: (response) => {
// Prepend new images to existing ones // Prepend new images to existing ones
setPage(response.props.pagination.current_page) setPage(response.props.pagination.current_page)
setLoading(false)
setHasMore(maxVisiblePage < response.props.pagination.total_pages) setHasMore(maxVisiblePage < response.props.pagination.total_pages)
console.log('response', response);
setLoadedPageMap({...loadedPageMap, [response.props.pagination.current_page]: response.props.images}) setLoadedPageMap({...loadedPageMap, [response.props.pagination.current_page]: response.props.images})
setLoading(false)
}, },
onError: () => { onError: () => {
console.error('Error loading images') console.error('Error loading images')
...@@ -96,22 +79,20 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -96,22 +79,20 @@ export default function Index({ images, filters, pagination, auth }) {
console.error('Error loading images:', error) console.error('Error loading images:', error)
setLoading(false) setLoading(false)
} }
}) }
const setVisibleImages = async (expectPage) =>{ const setVisibleImages = async (expectPage) =>{
if (!loadedPageMap[expectPage] && expectPage > maxVisiblePage) { if (!loadedPageMap[expectPage]) {
await loadPage(expectPage) await loadPage(expectPage)
} }
setAllImages([...allImages, ...loadedPageMap[expectPage]]) setAllImages(uniqBy([...allImages, ...(loadedPageMap[expectPage] || [])], (image) => image.id))
setMaxVisiblePage(expectPage) setMaxVisiblePage(expectPage)
} }
useEffect(() => { useEffect(() => {
if (page >= 1 && !loadedPageMap[1]) { if (page >= 1 && !loadedPageMap[1]) {
loadPage(1) setVisibleImages(1)
setMaxVisiblePage(1)
} else { } else {
loadPage(page)
setVisibleImages(page) setVisibleImages(page)
} }
}, [page, loadedPageMap, maxVisiblePage]) }, [page, loadedPageMap, maxVisiblePage])
...@@ -133,20 +114,6 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -133,20 +114,6 @@ export default function Index({ images, filters, pagination, auth }) {
if (node) bottomObserver.current.observe(node) if (node) bottomObserver.current.observe(node)
}, [loading, hasMore, loadMoreImages]) }, [loading, hasMore, loadMoreImages])
// Observer for the first image (top of the page)
// const firstImageElementRef = useCallback(node => {
// if (loading) return
// if (topObserver.current) topObserver.current.disconnect()
// topObserver.current = new IntersectionObserver(entries => {
// if (entries[0].isIntersecting && hasPrevious && page > 1) {
// loadPreviousImages()
// }
// }, { threshold: 0.5, rootMargin: '200px' })
// if (node) topObserver.current.observe(node)
// }, [loading, hasPrevious, page, loadPreviousImages])
return ( return (
<Layout user={auth}> <Layout user={auth}>
...@@ -159,35 +126,120 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -159,35 +126,120 @@ export default function Index({ images, filters, pagination, auth }) {
Manage your uploaded images Manage your uploaded images
</p> </p>
</div> </div>
{auth.user && ( <div className="flex space-x-2">
<Link <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"
>
<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 ? 'Hide Filters' : 'Show Filters'}
</button>
{auth && (
<Link
href="/images/new" 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" 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"
> >
Upload New Image Upload New Image
</Link> </Link>
)} )}
</div>
</div> </div>
<Tabs.Root defaultValue="all" className="px-4 sm:px-6 pb-5"> <div className="px-4 sm:px-6 pb-5">
<Tabs.List className="flex space-x-4 border-b border-gray-200 mb-4"> <Collapsible.Root open={isFilterOpen} onOpenChange={setIsFilterOpen} className="w-full">
<Tabs.Trigger <Collapsible.Content className="mb-6 mt-4 overflow-hidden">
value="all" <Form.Root
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" className="space-y-6 border border-gray-200 rounded-md p-4 bg-gray-50"
> onSubmit={handleSearch}
All Images >
</Tabs.Trigger> <div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<Tabs.Trigger <div className="sm:col-span-3">
value="search" <Form.Field name="title_cont">
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" <Form.Label className="block text-sm font-medium text-gray-700">
> Title
Search </Form.Label>
</Tabs.Trigger> <Form.Control asChild>
</Tabs.List> <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="Search by title"
/>
</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">
Tags
</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="Search by tag"
/>
</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">
From Date
</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">
To Date
</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>
<Tabs.Content value="all" className="focus:outline-none"> <div className="flex justify-end">
{allImages.length > 0 ? ( <button
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> 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>
</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 && ( {loading && (
<div className="col-span-full text-center py-4"> <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"> <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">
...@@ -231,7 +283,7 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -231,7 +283,7 @@ export default function Index({ images, filters, pagination, auth }) {
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{image.tags.map((tag) => ( {image.tags.map((tag) => (
<span <span
key={tag.id} 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" 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} {tag.name}
...@@ -296,105 +348,15 @@ export default function Index({ images, filters, pagination, auth }) { ...@@ -296,105 +348,15 @@ export default function Index({ images, filters, pagination, auth }) {
)} )}
</div> </div>
)} )}
{loading && allImages.length > 0 && ( {loading && allImages.length > 0 && (
<div className="flex justify-center mt-6 pb-6"> <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"> <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> <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> <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> </svg>
</div> </div>
)} )}
</Tabs.Content> </div>
<Tabs.Content value="search" className="focus:outline-none">
<Form.Root
className="space-y-6"
action="/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[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> </div>
</Layout> </Layout>
) )
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"@inertiajs/react": "^2.0.5", "@inertiajs/react": "^2.0.5",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-form": "^0.1.2", "@radix-ui/react-form": "^0.1.2",
...@@ -21,6 +22,7 @@ ...@@ -21,6 +22,7 @@
"@tailwindcss/vite": "^4.0.12", "@tailwindcss/vite": "^4.0.12",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"lodash-es": "^4.17.21",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwindcss": "^4.0.12" "tailwindcss": "^4.0.12"
...@@ -916,6 +918,36 @@ ...@@ -916,6 +918,36 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz",
"integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
...@@ -3150,6 +3182,12 @@ ...@@ -3150,6 +3182,12 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.castarray": { "node_modules/lodash.castarray": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
"@inertiajs/react": "^2.0.5", "@inertiajs/react": "^2.0.5",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-form": "^0.1.2", "@radix-ui/react-form": "^0.1.2",
...@@ -22,6 +23,7 @@ ...@@ -22,6 +23,7 @@
"@tailwindcss/vite": "^4.0.12", "@tailwindcss/vite": "^4.0.12",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"lodash-es": "^4.17.21",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwindcss": "^4.0.12" "tailwindcss": "^4.0.12"
......
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