import { useEffect, useMemo, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { Briefcase, Plus, RotateCcw, Search } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { Breadcrumbs } from '../components/Breadcrumbs' import { ListEmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' import { PortfolioCard } from '../components/PortfolioCard' import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { Pagination } from '../components/Pagination' import { useToast } from '../context/ToastContext' import { useT } from '../i18n/useT' import { ApiError } from '../lib/api' import { PORTFOLIOS_PER_PAGE, deletePortfolio, listPortfolios, updatePortfolio, } from '../services/portfolioService' import { listPortfolioCategories, mapPortfolioCategoryToUi, } from '../services/portfolioCategoryService' import type { FlatCategory } from '../types/category' import type { Portfolio } from '../types/portfolio' import { flattenCategories } from '../utils/categories' import pageStyles from '../components/PageContent.module.css' import filterStyles from '../components/ListFiltersPanel.module.css' import styles from './BlogPage.module.css' function readListQuery(searchParams: URLSearchParams) { const name = searchParams.get('q')?.trim() ?? '' const categoryId = searchParams.get('categoryId')?.trim() ?? '' const pageRaw = Number(searchParams.get('page')) const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1 return { name, categoryId, page } } function categoryFilterLabel(category: FlatCategory, isFa: boolean) { const name = isFa ? category.nameFa || category.nameEn : category.nameEn || category.nameFa return category.depth > 0 ? `${'\u2014 '.repeat(category.depth)}${name}` : name } export function PortfolioListPage() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const { showToast } = useToast() const t = useT() const { locale } = useLocale() const isFa = locale === 'fa' const { name: appliedName, categoryId: appliedCategoryId, page: currentPage, } = readListQuery(searchParams) const [portfolios, setPortfolios] = useState([]) const [totalPortfolios, setTotalPortfolios] = useState(0) const [isLoading, setIsLoading] = useState(true) const [isDeleting, setIsDeleting] = useState(false) const [movingUpId, setMovingUpId] = useState(null) const [error, setError] = useState('') const [commentCounts, setCommentCounts] = useState>({}) const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null) const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>( null, ) const [draftName, setDraftName] = useState(appliedName) const [draftCategoryId, setDraftCategoryId] = useState(appliedCategoryId) const [categoryOptions, setCategoryOptions] = useState([]) const totalPages = Math.max(1, Math.ceil(totalPortfolios / PORTFOLIOS_PER_PAGE)) const hasFilters = Boolean(appliedName || appliedCategoryId) const categorySelectOptions = useMemo( () => categoryOptions.map((category) => ({ id: category.id, label: categoryFilterLabel(category, isFa), })), [categoryOptions, isFa], ) useEffect(() => { setDraftName(appliedName) setDraftCategoryId(appliedCategoryId) }, [appliedName, appliedCategoryId]) useEffect(() => { const controller = new AbortController() void loadPortfolios(currentPage, controller.signal) return () => controller.abort() }, [currentPage, appliedName, appliedCategoryId]) useEffect(() => { const controller = new AbortController() void loadCategories(controller.signal) return () => controller.abort() }, []) function writeListQuery(name: string, categoryId: string, page: number) { const next = new URLSearchParams() if (name) next.set('q', name) if (categoryId) next.set('categoryId', categoryId) if (page > 1) next.set('page', String(page)) setSearchParams(next, { replace: true }) } async function loadCategories(signal?: AbortSignal) { try { const items = await listPortfolioCategories(signal) setCategoryOptions(flattenCategories(items.map(mapPortfolioCategoryToUi))) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return } } async function loadPortfolios(page: number, signal?: AbortSignal) { setIsLoading(true) setError('') try { const data = await listPortfolios(page, PORTFOLIOS_PER_PAGE, signal, { ...(appliedName ? { title: appliedName } : {}), ...(appliedCategoryId ? { categoryId: appliedCategoryId } : {}), }) setPortfolios(data.items) setTotalPortfolios(data.total) setCommentCounts( Object.fromEntries(data.items.map((item) => [item.id, item.commentCount ?? 0])), ) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof ApiError) { setError(err.message) } else { setError(t('portfolio.list.errorLoad')) } } finally { setIsLoading(false) } } function applyFilters() { writeListQuery(draftName.trim(), draftCategoryId, 1) } function clearFilters() { setDraftName('') setDraftCategoryId('') writeListQuery('', '', 1) } function handleEdit(id: string) { navigate(`/portfolios/edit/${id}`) } function handleComments(id: string) { const portfolio = portfolios.find((item) => item.id === id) if (portfolio) { setCommentsTarget({ id, title: portfolio.title }) } } function handleRemoveRequest(id: string) { const portfolio = portfolios.find((item) => item.id === id) if (portfolio) { setDeleteTarget({ id, title: portfolio.title }) } } async function handleMoveUp(id: string) { const index = portfolios.findIndex((item) => item.id === id) if (index < 0) return if (index === 0 && currentPage === 1) return setMovingUpId(id) setError('') try { await updatePortfolio(id, { moveToTop: true }) showToast(t('portfolio.list.toast.movedUp'), 'success') await loadPortfolios(currentPage) } catch (err) { if (err instanceof ApiError) { setError(err.message) } else { setError(t('portfolio.list.errorMove')) } } finally { setMovingUpId(null) } } async function confirmDelete() { if (!deleteTarget) return setIsDeleting(true) setError('') try { await deletePortfolio(deleteTarget.id) showToast(t('portfolio.list.toast.removed'), 'success') const nextTotal = totalPortfolios - 1 const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PORTFOLIOS_PER_PAGE)) const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage setDeleteTarget(null) writeListQuery(appliedName, appliedCategoryId, nextPage) await loadPortfolios(nextPage) } catch (err) { if (err instanceof ApiError) { setError(err.message) } else { setError(t('portfolio.list.errorDelete')) } } finally { setIsDeleting(false) } } function handlePageChange(page: number) { writeListQuery(appliedName, appliedCategoryId, page) window.scrollTo({ top: 0, behavior: 'smooth' }) } function handleCommentCountChange(count: number) { if (!commentsTarget) return setCommentCounts((prev) => ({ ...prev, [commentsTarget.id]: count, })) } return (
{t('title.myPortfolios')}

{t('portfolio.list.subtitle', { count: totalPortfolios })}

{t('portfolio.list.filters')}
{ e.preventDefault() applyFilters() }} >
setDraftName(e.target.value)} placeholder={t('portfolio.list.filterNamePlaceholder')} aria-label={t('portfolio.list.filterName')} disabled={isLoading} />
{error && (
{error}
)} {isLoading ? (

{t('portfolio.list.loading')}

) : portfolios.length === 0 ? ( hasFilters ? (

{t('portfolio.list.emptyFiltered')}

) : ( ) ) : ( <>
{portfolios.map((portfolio, index) => ( ))}
)} !isDeleting && setDeleteTarget(null)} /> {commentsTarget && ( setCommentsTarget(null)} onCountChange={handleCommentCountChange} /> )}
) }