ComImageIndex.jsx 16.2 KB
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'
import { ComImageCard } from './ComImageCard'
import TagSearchInput from '../TagSearchInput'
import DateRangePicker from '../DateRangePicker'
import ComImageStatusSelect from './ComImageStatusSelect'

export default function ComImageIndex({ title, description, path, images, pagination, filters, auth, backLink, actionButton, showUserName = false, allTags = {} }) {
  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 || '',
    status_eq: filters.status_eq || '',
  })
  const [isFilterOpen, setIsFilterOpen] = useState(false)

  const handleInputChange = (e) => {
    const { name, value } = e.target
    setSearchParams({ ...searchParams, [name]: value })
  }

  const handleTagsChange = (value) => {
    setSearchParams({ ...searchParams, tags_id_in: value })
  }

  const handleStatusChange = (value) => {
    const statusValue = value === 'all' ? '' : value
    setSearchParams({ ...searchParams, status_eq: statusValue })
  }
  
  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 ? searchParams.created_at_gteq : '',
              created_at_lteq: searchParams.created_at_lteq ? searchParams.created_at_lteq : '',
              status_eq: searchParams.status_eq
            }
          },
          preserveState: true,
          preserveScroll: true,
          replace: true,
          only: ['images', 'pagination'],
          onSuccess: (response) => {
            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 flex-col space-y-0 sm:flex-row">
          <button
            onClick={() => setIsFilterOpen(!isFilterOpen)}
            className={`inline-flex items-center justify-center mb-2 ml-2 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 justify-center mb-2 ml-2 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="tags_id_in">
                    <Form.Label className="block text-sm font-medium text-gray-700">
                      选择标签
                    </Form.Label>
                    <Form.Control asChild>
                      <TagSearchInput
                        value={searchParams.tags_id_in}
                        onChange={handleTagsChange}
                        allTags={allTags}
                        placeholder="选择标签进行筛选"
                      />
                    </Form.Control>
                  </Form.Field>
                </div>

                <div className="sm:col-span-3">
                  <Form.Field name="status_eq">
                    <Form.Label className="block text-sm font-medium text-gray-700">
                      状态
                    </Form.Label>
                    <Form.Control asChild>
                      <ComImageStatusSelect
                        value={searchParams.status_eq || "all"}
                        onChange={handleStatusChange}
                      />
                    </Form.Control>
                  </Form.Field>
                </div>

                <div className="sm:col-span-6">
                  <Form.Field name="date_range">
                    <Form.Label className="block text-sm font-medium text-gray-700">
                      日期范围
                    </Form.Label>
                    <Form.Control asChild>
                      <DateRangePicker
                        startDate={searchParams.created_at_gteq}
                        endDate={searchParams.created_at_lteq}
                        onChange={(startDate, endDate) => {
                          const newParams = { ...searchParams, created_at_gteq: startDate, created_at_lteq: endDate }
                          setSearchParams(newParams)
                        }}
                      />
                    </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: '',
                      tags_id_in: [],
                      created_at_gteq: '',
                      created_at_lteq: '',
                      status_eq: '',
                    })
                  }}
                >
                  清空
                </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) => (
                <ComImageCard
                  key={image.id}
                  ref={index === allImages.length - 1 ? lastImageElementRef : null}
                  image={image}
                  path={path}
                  showUserName={showUserName}
                  // showActions={showActions}
                />
              ))}
            </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>
        )}
        {
          !loading && allImages.length > 0 && !hasMore && (<div className="flex justify-center mt-6 pb-6">没有更多了</div>)
        }
      </div> 
    </>
  )
}