From dfd1dfb1186ad350a6d9b614eb05be33cda0cf04 Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Sat, 13 Sep 2025 00:49:06 -0700 Subject: [PATCH 01/12] Initial filtering and sorting UI --- app/fund/components/FundPageContent.tsx | 90 +++++++- app/fund/previously-funded/page.tsx | 5 - components/Comment/lib/ReviewExtension.tsx | 15 +- components/Fund/FundingSelector.tsx | 246 +++++++++++++++++++++ components/Fund/MarketplaceTabs.tsx | 8 +- components/ui/RightSidebarBanner.tsx | 2 +- hooks/useFeed.ts | 6 +- next.config.js | 5 + services/feed.service.ts | 2 + 9 files changed, 354 insertions(+), 25 deletions(-) delete mode 100644 app/fund/previously-funded/page.tsx create mode 100644 components/Fund/FundingSelector.tsx diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index f0b8e691e..8118c9c39 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -8,28 +8,63 @@ 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, Hub } from '@/components/Fund/FundingSelector'; 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_votes=${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 +72,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 +83,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 +101,47 @@ 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/components/Comment/lib/ReviewExtension.tsx b/components/Comment/lib/ReviewExtension.tsx index ced3ba866..230ab5832 100644 --- a/components/Comment/lib/ReviewExtension.tsx +++ b/components/Comment/lib/ReviewExtension.tsx @@ -1,7 +1,7 @@ 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 +10,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 +43,17 @@ export const ReviewStars = ({ ))} + {isClearable && ( + + )} ); diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx new file mode 100644 index 000000000..e77378179 --- /dev/null +++ b/components/Fund/FundingSelector.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { useEffect, useState, useCallback, useRef } from 'react'; +import { + MultiSelectOption, + SearchableMultiSelect, +} from '@/components/ui/form/SearchableMultiSelect'; +import { Badge } from '@/components/ui/Badge'; +import { ChevronDown, Filter } from 'lucide-react'; +import { BaseMenu } from '@/components/ui/form/BaseMenu'; +import { Topic } from '@/types/topic'; +import { Field, Input, Label, Switch } from '@headlessui/react'; +import { ReviewStars } from '../Comment/lib/ReviewExtension'; +import { HubService } from '@/services/hub.service'; + +export interface Hub { + id: string | number; + name: string; + description?: string; + color?: string; +} + +interface FundingSelectorProps { + selectedHubs: Hub[]; + onHubsChange: (hubs: Hub[]) => void; + selectedVotes: number; + onVotesChange: (votes: number) => void; + selectedScore: number; + onScoreChange: (score: number) => void; + selectedVerifiedAuthorsOnly: boolean; + onVerifiedAuthorsOnlyChange: (verifiedOnly: boolean) => void; + selectedTaxDeductible: boolean; + onTaxDeductibleChange: (taxDeductible: boolean) => void; + selectedPreviouslyFunded: boolean; + onPreviouslyFundedChange: (previouslyFunded: boolean) => void; + error?: string | null; + hideSelectedItems?: boolean; +} + +export function FundingSelector({ + selectedHubs, + onHubsChange, + selectedVotes, + onVotesChange, + selectedScore, + onScoreChange, + selectedVerifiedAuthorsOnly, + onVerifiedAuthorsOnlyChange, + selectedTaxDeductible, + onTaxDeductibleChange, + selectedPreviouslyFunded, + onPreviouslyFundedChange, + error, + hideSelectedItems = false, +}: 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); + })(); + }, []); + + // 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 + const filterHubs = useCallback( + async (query: string): Promise => { + if (!query) { + return hubsToOptions(topicsToHubs(allHubs)); + } + const lowered = query.toLowerCase(); + const filtered = allHubs.filter((hub) => hub.name.toLowerCase().includes(lowered)); + return hubsToOptions(topicsToHubs(filtered)); + }, + [allHubs, selectedHubs] + ); + + const fetchHubs = useCallback( + async (query: string): Promise => { + try { + const topics = await HubService.suggestTopics(query); + const hubs = topicsToHubs(topics); + return hubsToOptions(hubs); + } catch (error) { + console.error('Error fetching topics:', error); + return []; + } + }, + [selectedHubs] + ); + + const handleChange = (options: MultiSelectOption[]) => { + onHubsChange(optionsToHubs(options)); + }; + + const CustomSelectedItems = () => ( +
+ {selectedHubs.map((hub) => ( + { + e.preventDefault(); + e.stopPropagation(); + onHubsChange(selectedHubs.filter((h) => h.id !== hub.id)); + }} + > + {hub.color && ( +
+ )} + {hub.name} + + ))} +
+ ); + + 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-24 bg-transparent border-none p-1 text-sm outline-none" + /> + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} 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/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/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 = From 2d7568531e1fd5ab80fb2bed56aa82d6557655b4 Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Sat, 13 Sep 2025 23:46:47 -0700 Subject: [PATCH 02/12] Some clean up --- app/bounty/create/page.tsx | 5 +++-- app/earn/page.tsx | 11 ++++++----- app/fund/components/FundPageContent.tsx | 5 +++-- .../[id]/create/version/UploadVersionForm.tsx | 7 ++++--- app/paper/create/components/HubsSelector.tsx | 18 ++++++------------ app/paper/create/pdf/page.tsx | 7 ++++--- components/Earn/BountyHubSelector.tsx | 18 ++++++------------ components/Fund/FundingSelector.tsx | 18 ++++++------------ components/modals/QuestionEditModal.tsx | 5 +++-- types/contribution.ts | 14 ++++---------- types/hub.ts | 7 +++++++ 11 files changed, 52 insertions(+), 63 deletions(-) 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 8118c9c39..d25623f57 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -10,14 +10,15 @@ import { MarketplaceTabs, MarketplaceTab } from '@/components/Fund/MarketplaceTa import Icon from '@/components/ui/icons/Icon'; import SortDropdown, { SortOption } from '@/components/ui/SortDropdown'; import { useState } from 'react'; -import { FundingSelector, Hub } from '@/components/Fund/FundingSelector'; +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 [selectedHubs, setSelectedHubs] = useState([]); const [selectedVotes, setSelectedVotes] = useState(0); const [selectedScore, setSelectedScore] = useState(0); const [selectedVerifiedAuthorsOnly, setSelectedVerifiedAuthorsOnly] = useState(false); 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/Earn/BountyHubSelector.tsx b/components/Earn/BountyHubSelector.tsx index 5dcd87ead..9d578f7cd 100644 --- a/components/Earn/BountyHubSelector.tsx +++ b/components/Earn/BountyHubSelector.tsx @@ -11,17 +11,11 @@ import { X, 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'; interface BountyHubSelectorProps { - selectedHubs: Hub[]; - onChange: (hubs: Hub[]) => void; + selectedHubs: IHub[]; + onChange: (hubs: IHub[]) => void; error?: string | null; displayCountOnly?: boolean; hideSelectedItems?: boolean; @@ -70,17 +64,17 @@ export function BountyHubSelector({ }, []); // utility conversions - const hubsToOptions = (hubs: Hub[]): MultiSelectOption[] => + const hubsToOptions = (hubs: IHub[]): MultiSelectOption[] => hubs.map((hub) => ({ value: String(hub.id), label: hub.name })); - const topicsToHubs = (topics: Topic[]): Hub[] => + const topicsToHubs = (topics: Topic[]): IHub[] => topics.map((topic) => ({ id: topic.id, name: topic.name, description: topic.description, })); - const optionsToHubs = (options: MultiSelectOption[]): Hub[] => + const optionsToHubs = (options: MultiSelectOption[]): IHub[] => options.map( (opt) => selectedHubs.find((h) => String(h.id) === opt.value) || { diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index e77378179..56d9b9af1 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -12,17 +12,11 @@ import { Topic } from '@/types/topic'; import { Field, Input, Label, Switch } from '@headlessui/react'; import { ReviewStars } from '../Comment/lib/ReviewExtension'; import { HubService } from '@/services/hub.service'; - -export interface Hub { - id: string | number; - name: string; - description?: string; - color?: string; -} +import { IHub } from '@/types/hub'; interface FundingSelectorProps { - selectedHubs: Hub[]; - onHubsChange: (hubs: Hub[]) => void; + selectedHubs: IHub[]; + onHubsChange: (hubs: IHub[]) => void; selectedVotes: number; onVotesChange: (votes: number) => void; selectedScore: number; @@ -65,17 +59,17 @@ export function FundingSelector({ }, []); // utility conversions - const hubsToOptions = (hubs: Hub[]): MultiSelectOption[] => + const hubsToOptions = (hubs: IHub[]): MultiSelectOption[] => hubs.map((hub) => ({ value: String(hub.id), label: hub.name })); - const topicsToHubs = (topics: Topic[]): Hub[] => + const topicsToHubs = (topics: Topic[]): IHub[] => topics.map((topic) => ({ id: topic.id, name: topic.name, description: topic.description, })); - const optionsToHubs = (options: MultiSelectOption[]): Hub[] => + const optionsToHubs = (options: MultiSelectOption[]): IHub[] => options.map( (opt) => selectedHubs.find((h) => String(h.id) === opt.value) || { 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/types/contribution.ts b/types/contribution.ts index 9f8e7ce1a..0a79e6e87 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,7 +118,7 @@ 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( + const effectiveHubs: IHub[] = (hubs?.length ? hubs : item?.hubs?.length ? item.hubs : []).slice( 0, 2 ); 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; +} From 42cb4d9969d16b4d031d3ce84c9a6f4d93a03c10 Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Mon, 15 Sep 2025 09:34:54 -0700 Subject: [PATCH 03/12] Show multiple topics per proposal --- components/Feed/items/FeedItemFundraise.tsx | 12 ++--- components/ui/TopicAndJournalBadges.tsx | 60 +++++++++++++++++++++ types/feed.ts | 29 ++++++---- 3 files changed, 81 insertions(+), 20 deletions(-) create mode 100644 components/ui/TopicAndJournalBadges.tsx diff --git a/components/Feed/items/FeedItemFundraise.tsx b/components/Feed/items/FeedItemFundraise.tsx index ca65a485e..a6940ec94 100644 --- a/components/Feed/items/FeedItemFundraise.tsx +++ b/components/Feed/items/FeedItemFundraise.tsx @@ -17,6 +17,7 @@ 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; @@ -84,6 +85,7 @@ export const FeedItemFundraise: FC = ({ // Image URL const imageUrl = post.previewImage ?? undefined; + // debugger; return ( = ({ <> {isNonprofit && } - {topics.map((topic) => ( - - ))} + } /> diff --git a/components/ui/TopicAndJournalBadges.tsx b/components/ui/TopicAndJournalBadges.tsx new file mode 100644 index 000000000..9a678984b --- /dev/null +++ b/components/ui/TopicAndJournalBadges.tsx @@ -0,0 +1,60 @@ +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { TopicAndJournalBadge } from './TopicAndJournalBadge'; +import { Topic } from '@/types/topic'; +import { Plus } from 'lucide-react'; + +export interface TopicAndJournalBadgesProps { + 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/types/feed.ts b/types/feed.ts index a7d7c29d4..535b36178 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'; @@ -658,17 +659,23 @@ 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: content_object.hubs + ? (content_object.hubs as Array).map((hub, idx) => ({ + id: hub.id || idx, + name: hub.name || '', + slug: hub.slug || '', + })) + : content_object.hub + ? [ + content_object.hub.id + ? transformTopic(content_object.hub) + : { + id: 0, + name: content_object.hub.name || '', + slug: content_object.hub.slug || '', + }, + ] + : [], createdBy: transformAuthorProfile(author), bounties: content_object.bounties ? content_object.bounties.map((bounty: any) => From b7a466f5f3c2cc36c7de018925b79053d779b7a8 Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Mon, 15 Sep 2025 14:00:46 -0700 Subject: [PATCH 04/12] Fix min_upvotes filter and multiple topic rendering --- app/fund/components/FundPageContent.tsx | 2 +- components/ui/TopicAndJournalBadges.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index d25623f57..845749a3d 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -46,7 +46,7 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { filters.push(`hub_ids=${hubIds}`); } if (selectedVotes > 0) { - filters.push(`min_votes=${selectedVotes}`); + filters.push(`min_upvotes=${selectedVotes}`); } if (selectedScore > 0) { filters.push(`min_score=${selectedScore}`); diff --git a/components/ui/TopicAndJournalBadges.tsx b/components/ui/TopicAndJournalBadges.tsx index 9a678984b..141df20fa 100644 --- a/components/ui/TopicAndJournalBadges.tsx +++ b/components/ui/TopicAndJournalBadges.tsx @@ -36,13 +36,15 @@ export default function TopicAndJournalBadges({ topics }: TopicAndJournalBadgesP imageUrl={firstTopic.imageUrl} /> - - + + + + {additionalTopics.map((topic) => ( Date: Mon, 15 Sep 2025 15:57:57 -0700 Subject: [PATCH 05/12] Clean up filtering --- components/Fund/FundingSelector.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index 56d9b9af1..858d08e75 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -100,7 +100,7 @@ export function FundingSelector({ const hubs = topicsToHubs(topics); return hubsToOptions(hubs); } catch (error) { - console.error('Error fetching topics:', error); + console.error('Error searching topics:', error); return []; } }, @@ -161,9 +161,7 @@ export function FundingSelector({ trigger={trigger} align="start" sideOffset={5} - className="overflow-visible border-none p-0 shadow-lg !w-[300px] max-w-[90vw]" - // open={menuOpen} - // onOpenChange={setMenuOpen} + className="z-50 overflow-hidden rounded-lg border border-gray-200 bg-white p-1 shadow-md min-w-[8rem] w-[var(--trigger-width)] !w-[300px] max-w-[90vw]" >
From 0d00ec309f23b84a6b26f38b1bc545ffa9b194aa Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Mon, 15 Sep 2025 19:32:47 -0700 Subject: [PATCH 06/12] Use range input for slider control --- components/Fund/FundingSelector.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index 858d08e75..cfbeb40ea 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -177,14 +177,15 @@ export function FundingSelector({ className="w-full border-0 SearchableMultiSelect-input" />
- - - + + onVotesChange(Number(e.target.value))} - className="w-24 bg-transparent border-none p-1 text-sm outline-none" + className="w-full h-2" /> From f3615de9bd259a46f53b03e4f967a1668d467d1b Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Mon, 15 Sep 2025 22:12:16 -0700 Subject: [PATCH 07/12] Fix topic filtering search --- app/fund/components/FundPageContent.tsx | 1 - components/Fund/FundingSelector.tsx | 47 +++++++++---------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/app/fund/components/FundPageContent.tsx b/app/fund/components/FundPageContent.tsx index 845749a3d..90a65661f 100644 --- a/app/fund/components/FundPageContent.tsx +++ b/app/fund/components/FundPageContent.tsx @@ -130,7 +130,6 @@ export function FundPageContent({ marketplaceTab }: FundPageContentProps) { onTaxDeductibleChange={setSelectedTaxDeductible} selectedPreviouslyFunded={selectedPreviouslyFunded} onPreviouslyFundedChange={setSelectedPreviouslyFunded} - hideSelectedItems={true} />
diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index cfbeb40ea..427a29d86 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -28,7 +28,6 @@ interface FundingSelectorProps { selectedPreviouslyFunded: boolean; onPreviouslyFundedChange: (previouslyFunded: boolean) => void; error?: string | null; - hideSelectedItems?: boolean; } export function FundingSelector({ @@ -45,7 +44,6 @@ export function FundingSelector({ selectedPreviouslyFunded, onPreviouslyFundedChange, error, - hideSelectedItems = false, }: FundingSelectorProps) { const [allHubs, setAllHubs] = useState([]); const menuContentRef = useRef(null); @@ -80,34 +78,20 @@ export function FundingSelector({ const allHubOptions = hubsToOptions(topicsToHubs(allHubs)); - // Local search within allHubs - const filterHubs = useCallback( - async (query: string): Promise => { - if (!query) { - return hubsToOptions(topicsToHubs(allHubs)); - } - const lowered = query.toLowerCase(); - const filtered = allHubs.filter((hub) => hub.name.toLowerCase().includes(lowered)); - return hubsToOptions(topicsToHubs(filtered)); - }, - [allHubs, selectedHubs] - ); - - const fetchHubs = useCallback( - async (query: string): Promise => { - try { - const topics = await HubService.suggestTopics(query); - const hubs = topicsToHubs(topics); - return hubsToOptions(hubs); - } catch (error) { - console.error('Error searching topics:', error); - return []; - } - }, - [selectedHubs] - ); + 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 handleChange = (options: MultiSelectOption[]) => { + const handleTopicsChange = (options: MultiSelectOption[]) => { onHubsChange(optionsToHubs(options)); }; @@ -168,13 +152,14 @@ export function FundingSelector({

Topics

From f57c339aa42e8914048a27684ed99ee343c5b07d Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Tue, 16 Sep 2025 09:42:25 -0700 Subject: [PATCH 08/12] Remove unused code --- components/Earn/BountyHubSelector.tsx | 3 +-- components/Fund/FundingSelector.tsx | 24 +----------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/components/Earn/BountyHubSelector.tsx b/components/Earn/BountyHubSelector.tsx index 9d578f7cd..b2d567591 100644 --- a/components/Earn/BountyHubSelector.tsx +++ b/components/Earn/BountyHubSelector.tsx @@ -6,8 +6,7 @@ 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'; diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index 427a29d86..5197a1e74 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/Badge'; import { ChevronDown, Filter } from 'lucide-react'; import { BaseMenu } from '@/components/ui/form/BaseMenu'; import { Topic } from '@/types/topic'; -import { Field, Input, Label, Switch } from '@headlessui/react'; +import { Field, Label, Switch } from '@headlessui/react'; import { ReviewStars } from '../Comment/lib/ReviewExtension'; import { HubService } from '@/services/hub.service'; import { IHub } from '@/types/hub'; @@ -95,28 +95,6 @@ export function FundingSelector({ onHubsChange(optionsToHubs(options)); }; - const CustomSelectedItems = () => ( -
- {selectedHubs.map((hub) => ( - { - e.preventDefault(); - e.stopPropagation(); - onHubsChange(selectedHubs.filter((h) => h.id !== hub.id)); - }} - > - {hub.color && ( -
- )} - {hub.name} - - ))} -
- ); - const filtersUsed = selectedHubs.length + (selectedVotes > 0 ? 1 : 0) + From 9bc5bdd5f4a1da209e42fe80feaf07af1aaa7cf3 Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Tue, 16 Sep 2025 09:53:24 -0700 Subject: [PATCH 09/12] More unused import --- components/Fund/FundingSelector.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index 5197a1e74..431df1560 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -5,7 +5,6 @@ import { MultiSelectOption, SearchableMultiSelect, } from '@/components/ui/form/SearchableMultiSelect'; -import { Badge } from '@/components/ui/Badge'; import { ChevronDown, Filter } from 'lucide-react'; import { BaseMenu } from '@/components/ui/form/BaseMenu'; import { Topic } from '@/types/topic'; From 92fac831426e6dc197c8e4d7ed8447070ad6269d Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Tue, 16 Sep 2025 13:00:09 -0700 Subject: [PATCH 10/12] Refactoring --- components/Earn/BountyHubSelector.tsx | 23 +------- components/Fund/FundingSelector.tsx | 76 +++++++-------------------- components/shared/FilterSwitch.tsx | 34 ++++++++++++ utils/hubs.ts | 22 ++++++++ 4 files changed, 77 insertions(+), 78 deletions(-) create mode 100644 components/shared/FilterSwitch.tsx create mode 100644 utils/hubs.ts diff --git a/components/Earn/BountyHubSelector.tsx b/components/Earn/BountyHubSelector.tsx index b2d567591..4346a89d3 100644 --- a/components/Earn/BountyHubSelector.tsx +++ b/components/Earn/BountyHubSelector.tsx @@ -11,6 +11,7 @@ import { BaseMenu } from '@/components/ui/form/BaseMenu'; import { BountyService } from '@/services/bounty.service'; import { Topic } from '@/types/topic'; import { IHub } from '@/types/hub'; +import { hubsToOptions, optionsToHubs, topicsToHubs } from '@/utils/hubs'; interface BountyHubSelectorProps { selectedHubs: IHub[]; @@ -62,26 +63,6 @@ export function BountyHubSelector({ })(); }, []); - // utility conversions - const hubsToOptions = (hubs: IHub[]): MultiSelectOption[] => - hubs.map((hub) => ({ value: String(hub.id), label: hub.name })); - - const topicsToHubs = (topics: Topic[]): IHub[] => - topics.map((topic) => ({ - id: topic.id, - name: topic.name, - description: topic.description, - })); - - const optionsToHubs = (options: MultiSelectOption[]): IHub[] => - 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 @@ -98,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/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index 431df1560..badf69582 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -12,6 +12,8 @@ import { Field, Label, Switch } 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 { selectedHubs: IHub[]; @@ -55,26 +57,6 @@ export function FundingSelector({ })(); }, []); - // utility conversions - const hubsToOptions = (hubs: IHub[]): MultiSelectOption[] => - hubs.map((hub) => ({ value: String(hub.id), label: hub.name })); - - const topicsToHubs = (topics: Topic[]): IHub[] => - topics.map((topic) => ({ - id: topic.id, - name: topic.name, - description: topic.description, - })); - - const optionsToHubs = (options: MultiSelectOption[]): IHub[] => - options.map( - (opt) => - selectedHubs.find((h) => String(h.id) === opt.value) || { - id: opt.value, - name: opt.label, - } - ); - const allHubOptions = hubsToOptions(topicsToHubs(allHubs)); const handleTopicSearch = useCallback(async (query: string): Promise => { @@ -91,7 +73,7 @@ export function FundingSelector({ }, []); const handleTopicsChange = (options: MultiSelectOption[]) => { - onHubsChange(optionsToHubs(options)); + onHubsChange(optionsToHubs(options, selectedHubs)); }; const filtersUsed = @@ -159,42 +141,22 @@ export function FundingSelector({ label="" /> - - - - - - - - - - - - - - - - - - + + +
); diff --git a/components/shared/FilterSwitch.tsx b/components/shared/FilterSwitch.tsx new file mode 100644 index 000000000..74f269137 --- /dev/null +++ b/components/shared/FilterSwitch.tsx @@ -0,0 +1,34 @@ +import { Field, Label, Switch } from '@headlessui/react'; + +interface FilterSwitchProps { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + className?: string; +} + +export function FilterSwitch({ + label, + checked, + onChange, + className = 'pt-2 pb-2 border-b border-gray-200', +}: FilterSwitchProps) { + return ( + + + + + + + ); +} 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, + } + ); From 7031ff9d4a833d7f4cc98f602695debfd07d2d99 Mon Sep 17 00:00:00 2001 From: Josh Margulis Date: Tue, 16 Sep 2025 13:51:01 -0700 Subject: [PATCH 11/12] Clean up --- components/Comment/lib/ReviewExtension.tsx | 2 ++ components/Feed/items/FeedItemFundraise.tsx | 1 - components/shared/FilterSwitch.tsx | 2 ++ components/ui/TopicAndJournalBadge.tsx | 3 ++- components/ui/TopicAndJournalBadges.tsx | 2 ++ 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/components/Comment/lib/ReviewExtension.tsx b/components/Comment/lib/ReviewExtension.tsx index 230ab5832..ff9f5e42e 100644 --- a/components/Comment/lib/ReviewExtension.tsx +++ b/components/Comment/lib/ReviewExtension.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Node, Extension, mergeAttributes } from '@tiptap/core'; import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; import React from 'react'; diff --git a/components/Feed/items/FeedItemFundraise.tsx b/components/Feed/items/FeedItemFundraise.tsx index a6940ec94..fe9bd32c4 100644 --- a/components/Feed/items/FeedItemFundraise.tsx +++ b/components/Feed/items/FeedItemFundraise.tsx @@ -85,7 +85,6 @@ export const FeedItemFundraise: FC = ({ // Image URL const imageUrl = post.previewImage ?? undefined; - // debugger; return ( Date: Tue, 16 Sep 2025 14:05:40 -0700 Subject: [PATCH 12/12] More clean up --- components/Feed/items/FeedItemFundraise.tsx | 1 - components/Fund/FundingSelector.tsx | 28 ++++++++-------- components/shared/FilterSwitch.tsx | 8 ++--- components/ui/TopicAndJournalBadges.tsx | 2 +- types/contribution.ts | 11 +++--- types/feed.ts | 37 +++++++++++---------- 6 files changed, 46 insertions(+), 41 deletions(-) diff --git a/components/Feed/items/FeedItemFundraise.tsx b/components/Feed/items/FeedItemFundraise.tsx index fe9bd32c4..9e81dd31c 100644 --- a/components/Feed/items/FeedItemFundraise.tsx +++ b/components/Feed/items/FeedItemFundraise.tsx @@ -13,7 +13,6 @@ 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'; diff --git a/components/Fund/FundingSelector.tsx b/components/Fund/FundingSelector.tsx index badf69582..6dcd52d98 100644 --- a/components/Fund/FundingSelector.tsx +++ b/components/Fund/FundingSelector.tsx @@ -8,7 +8,7 @@ import { import { ChevronDown, Filter } from 'lucide-react'; import { BaseMenu } from '@/components/ui/form/BaseMenu'; import { Topic } from '@/types/topic'; -import { Field, Label, Switch } from '@headlessui/react'; +import { Field, Label } from '@headlessui/react'; import { ReviewStars } from '../Comment/lib/ReviewExtension'; import { HubService } from '@/services/hub.service'; import { IHub } from '@/types/hub'; @@ -16,19 +16,19 @@ import { hubsToOptions, optionsToHubs, topicsToHubs } from '@/utils/hubs'; import { FilterSwitch } from '../shared/FilterSwitch'; interface FundingSelectorProps { - selectedHubs: IHub[]; - onHubsChange: (hubs: IHub[]) => void; - selectedVotes: number; - onVotesChange: (votes: number) => void; - selectedScore: number; - onScoreChange: (score: number) => void; - selectedVerifiedAuthorsOnly: boolean; - onVerifiedAuthorsOnlyChange: (verifiedOnly: boolean) => void; - selectedTaxDeductible: boolean; - onTaxDeductibleChange: (taxDeductible: boolean) => void; - selectedPreviouslyFunded: boolean; - onPreviouslyFundedChange: (previouslyFunded: boolean) => void; - error?: string | null; + 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({ diff --git a/components/shared/FilterSwitch.tsx b/components/shared/FilterSwitch.tsx index d0b694a4e..18d8387ad 100644 --- a/components/shared/FilterSwitch.tsx +++ b/components/shared/FilterSwitch.tsx @@ -3,10 +3,10 @@ import { Field, Label, Switch } from '@headlessui/react'; interface FilterSwitchProps { - label: string; - checked: boolean; - onChange: (checked: boolean) => void; - className?: string; + readonly label: string; + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; + readonly className?: string; } export function FilterSwitch({ diff --git a/components/ui/TopicAndJournalBadges.tsx b/components/ui/TopicAndJournalBadges.tsx index c65ab744d..fc0b7d49c 100644 --- a/components/ui/TopicAndJournalBadges.tsx +++ b/components/ui/TopicAndJournalBadges.tsx @@ -6,7 +6,7 @@ import { Topic } from '@/types/topic'; import { Plus } from 'lucide-react'; export interface TopicAndJournalBadgesProps { - topics: Topic[]; + readonly topics: Topic[]; } export default function TopicAndJournalBadges({ topics }: TopicAndJournalBadgesProps) { diff --git a/types/contribution.ts b/types/contribution.ts index 0a79e6e87..8fb3ed072 100644 --- a/types/contribution.ts +++ b/types/contribution.ts @@ -118,10 +118,13 @@ export const transformContributionToFeedEntry = ({ }): FeedEntry => { const { content_type, created_by, created_date, hubs, item } = contribution; - const effectiveHubs: IHub[] = (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 535b36178..7a4548e3e 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -644,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, @@ -659,23 +678,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { ? content_object.authors.map(transformAuthorProfile) : [transformAuthorProfile(author)], institution: content_object.institution, // Populate institution - topics: content_object.hubs - ? (content_object.hubs as Array).map((hub, idx) => ({ - id: hub.id || idx, - name: hub.name || '', - slug: hub.slug || '', - })) - : 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) =>