diff --git a/app/bounty/create/page.tsx b/app/bounty/create/page.tsx index a779d218c..db905ea46 100644 --- a/app/bounty/create/page.tsx +++ b/app/bounty/create/page.tsx @@ -11,7 +11,7 @@ import { WorkSuggestion } from '@/types/search'; import { CommentEditor } from '@/components/Comment/CommentEditor'; import { JSONContent } from '@tiptap/core'; import { SessionProvider, useSession } from 'next-auth/react'; -import { HubsSelector, Hub } from '@/app/paper/create/components/HubsSelector'; +import { HubsSelector } from '@/app/paper/create/components/HubsSelector'; import { Currency } from '@/types/root'; import { BountyType } from '@/types/bounty'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; @@ -42,6 +42,7 @@ import { Icon } from '@/components/ui/icons/Icon'; import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; import { extractUserMentions } from '@/components/Comment/lib/commentUtils'; import { removeCommentDraftById } from '@/components/Comment/lib/commentDraftStorage'; +import { IHub } from '@/types/hub'; // Wizard steps. // We intentionally separate review-specific and answer-specific steps. @@ -82,7 +83,7 @@ export default function CreateBountyPage() { const [questionTitle, setQuestionTitle] = useState(''); const [questionPlainText, setQuestionPlainText] = useState(''); const [questionHtml, setQuestionHtml] = useState(''); - const [selectedHubs, setSelectedHubs] = useState([]); + const [selectedHubs, setSelectedHubs] = useState([]); // Shared – amount / currency const [currency, setCurrency] = useState('RSC'); diff --git a/app/earn/page.tsx b/app/earn/page.tsx index 24a472708..ac4a4d0da 100644 --- a/app/earn/page.tsx +++ b/app/earn/page.tsx @@ -9,11 +9,12 @@ import { EarnRightSidebar } from '@/components/Earn/EarnRightSidebar'; import { Coins } from 'lucide-react'; import { MainPageHeader } from '@/components/ui/MainPageHeader'; import Icon from '@/components/ui/icons/Icon'; -import { BountyHubSelector as HubsSelector, Hub } from '@/components/Earn/BountyHubSelector'; +import { BountyHubSelector as HubsSelector } from '@/components/Earn/BountyHubSelector'; import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; import { Badge } from '@/components/ui/Badge'; import { X } from 'lucide-react'; import { useClickContext } from '@/contexts/ClickContext'; +import { IHub } from '@/types/hub'; export default function EarnPage() { const [bounties, setBounties] = useState([]); @@ -21,7 +22,7 @@ export default function EarnPage() { const [hasMore, setHasMore] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); - const [selectedHubs, setSelectedHubs] = useState([]); + const [selectedHubs, setSelectedHubs] = useState([]); const [sort, setSort] = useState('personalized'); // Click context for topic filter @@ -35,7 +36,7 @@ export default function EarnPage() { { label: 'RSC amount', value: '-total_amount' }, ]; - const fetchBounties = async (reset = false, hubs: Hub[] = selectedHubs) => { + const fetchBounties = async (reset = false, hubs: IHub[] = selectedHubs) => { if (reset) { // Clear current bounties so skeleton loaders show instead of stale data setBounties([]); @@ -76,7 +77,7 @@ export default function EarnPage() { } }; - const handleHubsChange = (hubs: Hub[]) => { + const handleHubsChange = (hubs: IHub[]) => { setSelectedHubs(hubs); // Reset pagination and fetch bounties based on new hubs selection setPage(1); @@ -104,7 +105,7 @@ export default function EarnPage() { useEffect(() => { if (event && event.type === 'topic') { const topic = event.payload; - const newHub: Hub = { + const newHub: IHub = { id: topic.id, name: topic.name, description: topic.description, diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index f0b8e691e..90a65661f 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -8,28 +8,64 @@ import { GrantRightSidebar } from '@/components/Fund/GrantRightSidebar'; import { MainPageHeader } from '@/components/ui/MainPageHeader'; import { MarketplaceTabs, MarketplaceTab } from '@/components/Fund/MarketplaceTabs'; import Icon from '@/components/ui/icons/Icon'; +import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; +import { useState } from 'react'; +import { FundingSelector } from '@/components/Fund/FundingSelector'; +import { IHub } from '@/types/hub'; interface FundPageContentProps { marketplaceTab: MarketplaceTab; } export function FundPageContent({ marketplaceTab }: FundPageContentProps) { + const [selectedHubs, setSelectedHubs] = useState([]); + const [selectedVotes, setSelectedVotes] = useState(0); + const [selectedScore, setSelectedScore] = useState(0); + const [selectedVerifiedAuthorsOnly, setSelectedVerifiedAuthorsOnly] = useState(false); + const [selectedTaxDeductible, setSelectedTaxDeductible] = useState(false); + const [selectedPreviouslyFunded, setSelectedPreviouslyFunded] = useState(false); + const [sort, setSort] = useState('amount_raised'); + const getFundraiseStatus = (tab: MarketplaceTab): 'OPEN' | 'CLOSED' | undefined => { - if (tab === 'needs-funding') return 'OPEN'; - if (tab === 'previously-funded') return 'CLOSED'; + if (tab === 'needs-funding') { + return selectedPreviouslyFunded ? 'CLOSED' : 'OPEN'; + } return undefined; }; const getOrdering = (tab: MarketplaceTab): string | undefined => { - if (tab === 'needs-funding') return 'amount_raised'; + if (tab === 'needs-funding') return sort; return undefined; }; + const getFiltering = (tab: MarketplaceTab): string | undefined => { + if (tab !== 'needs-funding') return undefined; + const filters = []; + if (selectedHubs.length > 0) { + const hubIds = selectedHubs.map((hub) => hub.id).join(','); + filters.push(`hub_ids=${hubIds}`); + } + if (selectedVotes > 0) { + filters.push(`min_upvotes=${selectedVotes}`); + } + if (selectedScore > 0) { + filters.push(`min_score=${selectedScore}`); + } + if (selectedVerifiedAuthorsOnly) { + filters.push(`verified_authors_only=true`); + } + if (selectedTaxDeductible) { + filters.push(`tax_deductible=true`); + } + return filters.length > 0 ? encodeURIComponent(filters.join('&')) : undefined; + }; + const { entries, isLoading, hasMore, loadMore } = useFeed('all', { contentType: marketplaceTab === 'grants' ? 'GRANT' : 'PREREGISTRATION', endpoint: marketplaceTab === 'grants' ? 'grant_feed' : 'funding_feed', fundraiseStatus: getFundraiseStatus(marketplaceTab), - ordering: getOrdering(marketplaceTab), + ordering: marketplaceTab === 'grants' ? undefined : getOrdering(marketplaceTab), + filtering: marketplaceTab === 'grants' ? undefined : getFiltering(marketplaceTab), }); const getTitle = (tab: MarketplaceTab): string => { @@ -37,9 +73,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { case 'grants': return 'Request for Proposals'; case 'needs-funding': - return 'Proposals'; - case 'previously-funded': - return 'Previously Funded'; + return selectedPreviouslyFunded ? 'Previously Funded' : 'Proposals'; default: return ''; } @@ -50,9 +84,9 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { case 'grants': return 'Explore available funding opportunities'; case 'needs-funding': - return 'Fund breakthrough research shaping tomorrow'; - case 'previously-funded': - return 'Browse research that has been successfully funded'; + return selectedPreviouslyFunded + ? 'Browse research that has been successfully funded' + : 'Fund breakthrough research shaping tomorrow'; default: return ''; } @@ -68,10 +102,46 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { const rightSidebar = marketplaceTab === 'grants' ? : ; + const sortOptions = [ + { label: 'Best', value: 'amount_raised' }, + { label: 'Newest', value: 'newest' }, + { label: 'Expiring soon', value: 'expiring_soon' }, + { label: 'Near goal', value: 'goal_percent' }, + ]; + return ( {header} {}} /> + + {marketplaceTab === 'needs-funding' && ( +
+
+ +
+
+ setSort(opt.value)} + options={sortOptions} + /> +
+
+ )} + ; -} diff --git a/app/paper/[id]/create/version/UploadVersionForm.tsx b/app/paper/[id]/create/version/UploadVersionForm.tsx index a5a6aaba0..f4dbfaa81 100644 --- a/app/paper/[id]/create/version/UploadVersionForm.tsx +++ b/app/paper/[id]/create/version/UploadVersionForm.tsx @@ -12,13 +12,14 @@ import { AuthorsAndAffiliations, SelectedAuthor, } from '@/app/paper/create/components/AuthorsAndAffiliations'; -import { HubsSelector, Hub } from '@/app/paper/create/components/HubsSelector'; +import { HubsSelector } from '@/app/paper/create/components/HubsSelector'; import { FileText, FileUp, Users, Tags, ArrowLeft, Info, MessageCircle } from 'lucide-react'; import { UploadFileResult } from '@/services/file.service'; import { PaperService } from '@/services/paper.service'; import toast from 'react-hot-toast'; import { Work } from '@/types/work'; import { WorkMetadata } from '@/services/metadata.service'; +import { IHub } from '@/types/hub'; interface UploadVersionFormProps { initialPaper: Work; @@ -49,7 +50,7 @@ export default function UploadVersionForm({ isCorrespondingAuthor: auth.isCorresponding, })) ); - const [selectedHubs, setSelectedHubs] = useState(() => { + const [selectedHubs, setSelectedHubs] = useState(() => { const sourceTopics = metadata?.topics ?? initialPaper.topics; return sourceTopics.map((topic) => ({ id: topic.id, @@ -117,7 +118,7 @@ export default function UploadVersionForm({ } }; - const handleHubsChange = (newHubs: Hub[]) => { + const handleHubsChange = (newHubs: IHub[]) => { setSelectedHubs(newHubs); if (errors.hubs) { setErrors({ ...errors, hubs: null }); diff --git a/app/paper/create/components/HubsSelector.tsx b/app/paper/create/components/HubsSelector.tsx index ee596f469..70ed1e3d6 100644 --- a/app/paper/create/components/HubsSelector.tsx +++ b/app/paper/create/components/HubsSelector.tsx @@ -11,17 +11,11 @@ import { X, ChevronDown, Filter } from 'lucide-react'; import { BaseMenu } from '@/components/ui/form/BaseMenu'; import { HubService } from '@/services/hub.service'; import { Topic } from '@/types/topic'; - -export interface Hub { - id: string | number; - name: string; - description?: string; - color?: string; -} +import { IHub } from '@/types/hub'; interface HubsSelectorProps { - selectedHubs: Hub[]; - onChange: (hubs: Hub[]) => void; + selectedHubs: IHub[]; + onChange: (hubs: IHub[]) => void; error?: string | null; displayCountOnly?: boolean; hideSelectedItems?: boolean; @@ -35,7 +29,7 @@ export function HubsSelector({ hideSelectedItems = false, }: HubsSelectorProps) { // Convert hubs to the format expected by SearchableMultiSelect - const hubsToOptions = (hubs: Hub[]): MultiSelectOption[] => { + const hubsToOptions = (hubs: IHub[]): MultiSelectOption[] => { return hubs.map((hub) => ({ value: String(hub.id), label: hub.name, @@ -43,7 +37,7 @@ export function HubsSelector({ }; // Convert Topic to Hub - const topicsToHubs = (topics: Topic[]): Hub[] => { + const topicsToHubs = (topics: Topic[]): IHub[] => { return topics.map((topic) => ({ id: topic.id, name: topic.name, @@ -52,7 +46,7 @@ export function HubsSelector({ }; // Convert MultiSelectOption back to Hub objects - const optionsToHubs = (options: MultiSelectOption[]): Hub[] => { + const optionsToHubs = (options: MultiSelectOption[]): IHub[] => { return options.map((option) => { // Find the original hub in the selectedHubs array const existingHub = selectedHubs.find((hub) => String(hub.id) === option.value); diff --git a/app/paper/create/pdf/page.tsx b/app/paper/create/pdf/page.tsx index 625835cc4..f4b566837 100644 --- a/app/paper/create/pdf/page.tsx +++ b/app/paper/create/pdf/page.tsx @@ -10,7 +10,7 @@ import { Input } from '@/components/ui/form/Input'; import { FileUpload } from '@/components/ui/form/FileUpload'; import { SimpleStepProgress, SimpleStep } from '@/components/ui/SimpleStepProgress'; import { AuthorsAndAffiliations, SelectedAuthor } from '../components/AuthorsAndAffiliations'; -import { HubsSelector, Hub } from '../components/HubsSelector'; +import { HubsSelector } from '../components/HubsSelector'; import { DeclarationCheckbox } from '../components/DeclarationCheckbox'; import { ArrowLeft, @@ -30,6 +30,7 @@ import { Switch } from '@/components/ui/Switch'; import { AvatarStack } from '@/components/ui/AvatarStack'; import { useScreenSize } from '@/hooks/useScreenSize'; import { Callout } from '@/components/ui/Callout'; +import { IHub } from '@/types/hub'; // Define the steps of our flow const steps: SimpleStep[] = [ @@ -52,7 +53,7 @@ export default function UploadPDFPage() { const [abstract, setAbstract] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const [authors, setAuthors] = useState([]); - const [selectedHubs, setSelectedHubs] = useState([]); + const [selectedHubs, setSelectedHubs] = useState([]); const [changeDescription, setChangeDescription] = useState('Initial submission'); const [fileUploadResult, setFileUploadResult] = useState(null); @@ -151,7 +152,7 @@ export default function UploadPDFPage() { } }; - const handleHubsChange = (newHubs: Hub[]) => { + const handleHubsChange = (newHubs: IHub[]) => { setSelectedHubs(newHubs); if (errors.hubs) { setErrors({ ...errors, hubs: null }); diff --git a/components/Comment/lib/ReviewExtension.tsx b/components/Comment/lib/ReviewExtension.tsx index ced3ba866..ff9f5e42e 100644 --- a/components/Comment/lib/ReviewExtension.tsx +++ b/components/Comment/lib/ReviewExtension.tsx @@ -1,7 +1,9 @@ +'use client'; + import { Node, Extension, mergeAttributes } from '@tiptap/core'; import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; import React from 'react'; -import { Star } from 'lucide-react'; +import { Ban, Star } from 'lucide-react'; import { useState } from 'react'; // Common ReviewStars component used by both overall and section ratings @@ -10,12 +12,14 @@ export const ReviewStars = ({ onRatingChange, isRequired = false, isReadOnly = false, + isClearable = false, label = 'Overall rating:', }: { rating: number; onRatingChange: (rating: number) => void; isRequired?: boolean; isReadOnly?: boolean; + isClearable?: boolean; label?: string; }) => { const [hoverRating, setHoverRating] = useState(0); @@ -41,6 +45,17 @@ export const ReviewStars = ({ ))} + {isClearable && ( + + )} ); diff --git a/components/Earn/BountyHubSelector.tsx b/components/Earn/BountyHubSelector.tsx index 5dcd87ead..4346a89d3 100644 --- a/components/Earn/BountyHubSelector.tsx +++ b/components/Earn/BountyHubSelector.tsx @@ -6,22 +6,16 @@ import { SearchableMultiSelect, } from '@/components/ui/form/SearchableMultiSelect'; import { Badge } from '@/components/ui/Badge'; -import { Button } from '@/components/ui/Button'; -import { X, ChevronDown, Filter } from 'lucide-react'; +import { ChevronDown, Filter } from 'lucide-react'; import { BaseMenu } from '@/components/ui/form/BaseMenu'; import { BountyService } from '@/services/bounty.service'; import { Topic } from '@/types/topic'; - -export interface Hub { - id: string | number; - name: string; - description?: string; - color?: string; -} +import { IHub } from '@/types/hub'; +import { hubsToOptions, optionsToHubs, topicsToHubs } from '@/utils/hubs'; interface BountyHubSelectorProps { - selectedHubs: Hub[]; - onChange: (hubs: Hub[]) => void; + selectedHubs: IHub[]; + onChange: (hubs: IHub[]) => void; error?: string | null; displayCountOnly?: boolean; hideSelectedItems?: boolean; @@ -69,26 +63,6 @@ export function BountyHubSelector({ })(); }, []); - // utility conversions - const hubsToOptions = (hubs: Hub[]): MultiSelectOption[] => - hubs.map((hub) => ({ value: String(hub.id), label: hub.name })); - - const topicsToHubs = (topics: Topic[]): Hub[] => - topics.map((topic) => ({ - id: topic.id, - name: topic.name, - description: topic.description, - })); - - const optionsToHubs = (options: MultiSelectOption[]): Hub[] => - options.map( - (opt) => - selectedHubs.find((h) => String(h.id) === opt.value) || { - id: opt.value, - name: opt.label, - } - ); - const allHubOptions = hubsToOptions(topicsToHubs(allHubs)); // Local search within allHubs @@ -105,7 +79,7 @@ export function BountyHubSelector({ ); const handleChange = (options: MultiSelectOption[]) => { - onChange(optionsToHubs(options)); + onChange(optionsToHubs(options, selectedHubs)); if (displayCountOnly) { setMenuOpen(false); } diff --git a/components/Feed/items/FeedItemFundraise.tsx b/components/Feed/items/FeedItemFundraise.tsx index ca65a485e..9e81dd31c 100644 --- a/components/Feed/items/FeedItemFundraise.tsx +++ b/components/Feed/items/FeedItemFundraise.tsx @@ -13,10 +13,10 @@ import { } from '@/components/Feed/BaseFeedItem'; import { ContentTypeBadge } from '@/components/ui/ContentTypeBadge'; import { AuthorList } from '@/components/ui/AuthorList'; -import { TopicAndJournalBadge } from '@/components/ui/TopicAndJournalBadge'; import { TaxDeductibleBadge } from '@/components/ui/TaxDeductibleBadge'; import { FundraiseProgress } from '@/components/Fund/FundraiseProgress'; import { Users, Building, Pin } from 'lucide-react'; +import TopicAndJournalBadges from '@/components/ui/TopicAndJournalBadges'; interface FeedItemFundraiseProps { entry: FeedEntry; @@ -117,15 +117,7 @@ export const FeedItemFundraise: FC = ({ <> {isNonprofit && } - {topics.map((topic) => ( - - ))} + } /> diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx new file mode 100644 index 000000000..6dcd52d98 --- /dev/null +++ b/components/Fund/FundingSelector.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { useEffect, useState, useCallback, useRef } from 'react'; +import { + MultiSelectOption, + SearchableMultiSelect, +} from '@/components/ui/form/SearchableMultiSelect'; +import { ChevronDown, Filter } from 'lucide-react'; +import { BaseMenu } from '@/components/ui/form/BaseMenu'; +import { Topic } from '@/types/topic'; +import { Field, Label } from '@headlessui/react'; +import { ReviewStars } from '../Comment/lib/ReviewExtension'; +import { HubService } from '@/services/hub.service'; +import { IHub } from '@/types/hub'; +import { hubsToOptions, optionsToHubs, topicsToHubs } from '@/utils/hubs'; +import { FilterSwitch } from '../shared/FilterSwitch'; + +interface FundingSelectorProps { + readonly selectedHubs: IHub[]; + readonly onHubsChange: (hubs: IHub[]) => void; + readonly selectedVotes: number; + readonly onVotesChange: (votes: number) => void; + readonly selectedScore: number; + readonly onScoreChange: (score: number) => void; + readonly selectedVerifiedAuthorsOnly: boolean; + readonly onVerifiedAuthorsOnlyChange: (verifiedOnly: boolean) => void; + readonly selectedTaxDeductible: boolean; + readonly onTaxDeductibleChange: (taxDeductible: boolean) => void; + readonly selectedPreviouslyFunded: boolean; + readonly onPreviouslyFundedChange: (previouslyFunded: boolean) => void; + readonly error?: string | null; +} + +export function FundingSelector({ + selectedHubs, + onHubsChange, + selectedVotes, + onVotesChange, + selectedScore, + onScoreChange, + selectedVerifiedAuthorsOnly, + onVerifiedAuthorsOnlyChange, + selectedTaxDeductible, + onTaxDeductibleChange, + selectedPreviouslyFunded, + onPreviouslyFundedChange, + error, +}: FundingSelectorProps) { + const [allHubs, setAllHubs] = useState([]); + const menuContentRef = useRef(null); + + // fetch all hubs at mount + useEffect(() => { + (async () => { + const hubs = await HubService.getHubs({ excludeJournals: false }); + setAllHubs(hubs); + })(); + }, []); + + const allHubOptions = hubsToOptions(topicsToHubs(allHubs)); + + const handleTopicSearch = useCallback(async (query: string): Promise => { + try { + const topics = await HubService.suggestTopics(query); + return topics.map((topic) => ({ + value: topic.id.toString(), + label: topic.name, + })); + } catch (error) { + console.error('Error searching topics:', error); + return []; + } + }, []); + + const handleTopicsChange = (options: MultiSelectOption[]) => { + onHubsChange(optionsToHubs(options, selectedHubs)); + }; + + const filtersUsed = + selectedHubs.length + + (selectedVotes > 0 ? 1 : 0) + + (selectedScore > 0 ? 1 : 0) + + (selectedVerifiedAuthorsOnly ? 1 : 0) + + (selectedTaxDeductible ? 1 : 0) + + (selectedPreviouslyFunded ? 1 : 0); + const trigger = ( + + ); + + return ( + +
+
+

Topics

+ +
+ + + onVotesChange(Number(e.target.value))} + className="w-full h-2" + /> + + + + + + + + +
+
+ ); +} diff --git a/components/Fund/MarketplaceTabs.tsx b/components/Fund/MarketplaceTabs.tsx index 967a012d2..ef8894613 100644 --- a/components/Fund/MarketplaceTabs.tsx +++ b/components/Fund/MarketplaceTabs.tsx @@ -4,7 +4,7 @@ import { FC } from 'react'; import { Tabs } from '@/components/ui/Tabs'; import { useRouter } from 'next/navigation'; -export type MarketplaceTab = 'grants' | 'needs-funding' | 'previously-funded'; +export type MarketplaceTab = 'grants' | 'needs-funding'; interface MarketplaceTabsProps { activeTab: MarketplaceTab; @@ -28,10 +28,6 @@ export const MarketplaceTabs: FC = ({ id: 'needs-funding', label: 'Proposals', }, - { - id: 'previously-funded', - label: 'Previously Funded', - }, ]; const handleTabChange = (tabId: string) => { @@ -44,8 +40,6 @@ export const MarketplaceTabs: FC = ({ router.push('/fund/grants'); } else if (tab === 'needs-funding') { router.push('/fund/needs-funding'); - } else if (tab === 'previously-funded') { - router.push('/fund/previously-funded'); } onTabChange(tab); diff --git a/components/modals/QuestionEditModal.tsx b/components/modals/QuestionEditModal.tsx index 846b39f88..3943f1d7b 100644 --- a/components/modals/QuestionEditModal.tsx +++ b/components/modals/QuestionEditModal.tsx @@ -7,11 +7,12 @@ import { Input } from '@/components/ui/form/Input'; import { CommentEditor } from '@/components/Comment/CommentEditor'; import { CommentContent } from '@/components/Comment/lib/types'; import { SessionProvider, useSession } from 'next-auth/react'; -import { HubsSelector, Hub } from '@/app/paper/create/components/HubsSelector'; +import { HubsSelector } from '@/app/paper/create/components/HubsSelector'; import { Work } from '@/types/work'; import { PostService } from '@/services/post.service'; import { toast } from 'react-hot-toast'; import { useRouter } from 'next/navigation'; +import { IHub } from '@/types/hub'; interface QuestionEditModalProps { isOpen: boolean; @@ -27,7 +28,7 @@ export const QuestionEditModal = ({ isOpen, onClose, work }: QuestionEditModalPr const [title, setTitle] = useState(work.title); const [plainText, setPlainText] = useState(''); const [htmlContent, setHtmlContent] = useState(''); - const [selectedHubs, setSelectedHubs] = useState([]); + const [selectedHubs, setSelectedHubs] = useState([]); // Initialize hubs from work topics useEffect(() => { diff --git a/components/shared/FilterSwitch.tsx b/components/shared/FilterSwitch.tsx new file mode 100644 index 000000000..18d8387ad --- /dev/null +++ b/components/shared/FilterSwitch.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Field, Label, Switch } from '@headlessui/react'; + +interface FilterSwitchProps { + readonly label: string; + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; + readonly className?: string; +} + +export function FilterSwitch({ + label, + checked, + onChange, + className = 'pt-2 pb-2 border-b border-gray-200', +}: FilterSwitchProps) { + return ( + + + + + + + ); +} diff --git a/components/ui/RightSidebarBanner.tsx b/components/ui/RightSidebarBanner.tsx index e1de7ec92..55ec9996f 100644 --- a/components/ui/RightSidebarBanner.tsx +++ b/components/ui/RightSidebarBanner.tsx @@ -78,7 +78,7 @@ export const RightSidebarBanner: React.FC = ({ ))} - diff --git a/components/ui/TopicAndJournalBadge.tsx b/components/ui/TopicAndJournalBadge.tsx index f33f8e151..69f100974 100644 --- a/components/ui/TopicAndJournalBadge.tsx +++ b/components/ui/TopicAndJournalBadge.tsx @@ -1,4 +1,5 @@ -import { Avatar } from './Avatar'; +'use client'; + import Link from 'next/link'; export type BadgeType = 'topic' | 'journal'; diff --git a/components/ui/TopicAndJournalBadges.tsx b/components/ui/TopicAndJournalBadges.tsx new file mode 100644 index 000000000..fc0b7d49c --- /dev/null +++ b/components/ui/TopicAndJournalBadges.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { TopicAndJournalBadge } from './TopicAndJournalBadge'; +import { Topic } from '@/types/topic'; +import { Plus } from 'lucide-react'; + +export interface TopicAndJournalBadgesProps { + readonly topics: Topic[]; +} + +export default function TopicAndJournalBadges({ topics }: TopicAndJournalBadgesProps) { + if (!topics || topics.length === 0) { + return null; // No topics to display + } + const firstTopic = topics[0]; + if (topics.length === 1) { + return ( + + ); + } + + const additionalTopics = topics.slice(1); + + return ( + <> + + + + + + + + + {additionalTopics.map((topic) => ( + + ))} + + + + ); +} diff --git a/hooks/useFeed.ts b/hooks/useFeed.ts index 19c3fc8b5..896530146 100644 --- a/hooks/useFeed.ts +++ b/hooks/useFeed.ts @@ -15,6 +15,7 @@ interface UseFeedOptions { fundraiseStatus?: 'OPEN' | 'CLOSED'; createdBy?: number; ordering?: string; + filtering?: string; initialData?: { entries: FeedEntry[]; hasMore: boolean; @@ -66,7 +67,8 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions options.endpoint !== currentOptions.endpoint || options.fundraiseStatus !== currentOptions.fundraiseStatus || options.createdBy !== currentOptions.createdBy || - options.ordering !== currentOptions.ordering; + options.ordering !== currentOptions.ordering || + options.filtering !== currentOptions.filtering; if (relevantOptionsChanged) { setCurrentOptions(options); @@ -88,6 +90,7 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions fundraiseStatus: options.fundraiseStatus, createdBy: options.createdBy, ordering: options.ordering, + filtering: options.filtering, }); setEntries(result.entries); setHasMore(result.hasMore); @@ -116,6 +119,7 @@ export const useFeed = (activeTab: FeedTab | FundingTab, options: UseFeedOptions fundraiseStatus: options.fundraiseStatus, createdBy: options.createdBy, ordering: options.ordering, + filtering: options.filtering, }); setEntries((prev) => [...prev, ...result.entries]); setHasMore(result.hasMore); diff --git a/next.config.js b/next.config.js index 67ec23b61..7f7b12d6d 100644 --- a/next.config.js +++ b/next.config.js @@ -12,6 +12,11 @@ const nextConfig = { destination: '/journal', permanent: true, }, + { + source: '/fund/previously-funded', + destination: '/fund/needs-funding', + permanent: true, + }, ], headers: async () => [ { diff --git a/services/feed.service.ts b/services/feed.service.ts index f58822b26..9d1b5d420 100644 --- a/services/feed.service.ts +++ b/services/feed.service.ts @@ -22,6 +22,7 @@ export class FeedService { grantId?: number; createdBy?: number; ordering?: string; + filtering?: string; }): Promise<{ entries: FeedEntry[]; hasMore: boolean }> { const queryParams = new URLSearchParams(); if (params?.page) queryParams.append('page', params.page.toString()); @@ -34,6 +35,7 @@ export class FeedService { if (params?.grantId) queryParams.append('grant_id', params.grantId.toString()); if (params?.createdBy) queryParams.append('created_by', params.createdBy.toString()); if (params?.ordering) queryParams.append('ordering', params.ordering); + if (params?.filtering) queryParams.append('filtering', params.filtering); // Determine which endpoint to use const basePath = diff --git a/types/contribution.ts b/types/contribution.ts index 9f8e7ce1a..8fb3ed072 100644 --- a/types/contribution.ts +++ b/types/contribution.ts @@ -6,13 +6,7 @@ import { transformBounty } from './bounty'; import { Work } from './work'; import { ContributionType } from '@/services/contribution.service'; import { stripHtml } from '@/utils/stringUtils'; - -export interface Hub { - id: ID; - name: string; - hub_image: string; - slug: string; -} +import { IHub } from './hub'; export interface AuthorProfile { id: ID; @@ -46,7 +40,7 @@ export interface Contribution { content_type: ContentType; created_by: User; created_date: string; - hubs: Hub[]; + hubs: IHub[]; item: ContributionItem; } @@ -90,7 +84,7 @@ const getContentType = (contentType: string): FeedContentType => { } }; -const transformUnifiedDocumentToWork = ({ raw, hubs }: { raw: any; hubs: Hub[] }): Work => { +const transformUnifiedDocumentToWork = ({ raw, hubs }: { raw: any; hubs: IHub[] }): Work => { const contentType = raw.unified_document?.document_type === 'PAPER' ? 'paper' @@ -124,10 +118,13 @@ export const transformContributionToFeedEntry = ({ }): FeedEntry => { const { content_type, created_by, created_date, hubs, item } = contribution; - const effectiveHubs: Hub[] = (hubs?.length ? hubs : item?.hubs?.length ? item.hubs : []).slice( - 0, - 2 - ); + let rawHubs: IHub[] = []; + if (hubs?.length) { + rawHubs = hubs; + } else if (item?.hubs?.length) { + rawHubs = item.hubs; + } + const effectiveHubs: IHub[] = rawHubs.slice(0, 2); // Base feed entry properties const baseFeedEntry: Partial = { diff --git a/types/feed.ts b/types/feed.ts index a7d7c29d4..7a4548e3e 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -11,6 +11,7 @@ import { UserVoteType } from './reaction'; import { User } from './user'; import { stripHtml } from '@/utils/stringUtils'; import { Tip } from './tip'; +import { Hub } from './hub'; export type FeedActionType = 'contribute' | 'open' | 'publish' | 'post'; @@ -643,6 +644,25 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { contentType = 'PREREGISTRATION'; } + let postTopics: Topic[] = []; + if (content_object.hubs) { + postTopics = (content_object.hubs as Array).map((hub, idx) => ({ + id: hub.id || idx, + name: hub.name || '', + slug: hub.slug || '', + })); + } else if (content_object.hub) { + postTopics = [ + content_object.hub.id + ? transformTopic(content_object.hub) + : { + id: 0, + name: content_object.hub.name || '', + slug: content_object.hub.slug || '', + }, + ]; + } + // Create a FeedPostEntry object const postEntry: FeedPostContent = { id: content_object.id, @@ -658,17 +678,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { ? content_object.authors.map(transformAuthorProfile) : [transformAuthorProfile(author)], institution: content_object.institution, // Populate institution - topics: content_object.hub - ? [ - content_object.hub.id - ? transformTopic(content_object.hub) - : { - id: 0, - name: content_object.hub.name || '', - slug: content_object.hub.slug || '', - }, - ] - : [], + topics: postTopics, createdBy: transformAuthorProfile(author), bounties: content_object.bounties ? content_object.bounties.map((bounty: any) => diff --git a/types/hub.ts b/types/hub.ts index 7ad162826..50625b1a3 100644 --- a/types/hub.ts +++ b/types/hub.ts @@ -6,3 +6,10 @@ export type Hub = { imageUrl?: string; description?: string; }; + +export interface IHub { + id: string | number; + name: string; + description?: string; + color?: string; +} diff --git a/utils/hubs.ts b/utils/hubs.ts new file mode 100644 index 000000000..6b3f83e30 --- /dev/null +++ b/utils/hubs.ts @@ -0,0 +1,22 @@ +import { MultiSelectOption } from '@/components/ui/form/SearchableMultiSelect'; +import { IHub } from '@/types/hub'; +import { Topic } from '@/types/topic'; + +export const hubsToOptions = (hubs: IHub[]): MultiSelectOption[] => + hubs.map((hub) => ({ value: String(hub.id), label: hub.name })); + +export const topicsToHubs = (topics: Topic[]): IHub[] => + topics.map((topic) => ({ + id: topic.id, + name: topic.name, + description: topic.description, + })); + +export const optionsToHubs = (options: MultiSelectOption[], existingHubs: IHub[]): IHub[] => + options.map( + (opt) => + existingHubs.find((h) => String(h.id) === opt.value) || { + id: opt.value, + name: opt.label, + } + );