diff --git a/app/my-funding/components/MyFundingPage.tsx b/app/my-funding/components/MyFundingPage.tsx index 9c42e9131..7b99baa3b 100644 --- a/app/my-funding/components/MyFundingPage.tsx +++ b/app/my-funding/components/MyFundingPage.tsx @@ -1,8 +1,9 @@ 'use client'; import { useEffect, type ReactNode } from 'react'; +import { FundingDirectionIcon } from '@/components/Funding/FundingDirectionIcon'; +import type { FundingDirection } from '@/components/Funding/fundingDirection'; import { useRouter, useSearchParams } from 'next/navigation'; -import { ArrowDownLeft, ArrowUpRight, type LucideIcon } from 'lucide-react'; import { PageLayout } from '@/app/layouts/PageLayout'; import { FundsGiven } from '@/components/Funding/dashboard/FundsGiven'; import { ModeratorViewAsFunder } from '@/components/Funding/dashboard/ModeratorViewAsFunder'; @@ -17,12 +18,10 @@ type MyFundingTab = 'given' | 'received'; * funds given, into it for funds received. The arrow inherits the tab's own * color so it picks up the active/inactive treatment for free. */ -function TabLabel({ icon: DirectionIcon, children }: { icon: LucideIcon; children: ReactNode }) { +function TabLabel({ direction, children }: { direction: FundingDirection; children: ReactNode }) { return ( - - - + {children} ); @@ -31,12 +30,12 @@ function TabLabel({ icon: DirectionIcon, children }: { icon: LucideIcon; childre const MY_FUNDING_TABS = [ { id: 'given', - label: Funds given, + label: Funds given, href: '/my-funding?tab=given', }, { id: 'received', - label: Funds received, + label: Funds received, href: '/my-funding?tab=received', // Money coming in reads green, matching the emerald treatment the Fund // tabs already use for the inbound side. diff --git a/components/AIMode/AIModeOverlay.tsx b/components/AIMode/AIModeOverlay.tsx index b49391ec0..01c6a0c03 100644 --- a/components/AIMode/AIModeOverlay.tsx +++ b/components/AIMode/AIModeOverlay.tsx @@ -14,7 +14,6 @@ import { ConversationList } from './ConversationList'; import { DocumentCard } from './DocumentCard'; import { DocumentPane, type DocumentPaneView } from './DocumentPane'; import { useAIModeChat } from './useAIModeChat'; -import type { PublishingDefaultArticleType } from '@/contexts/PublishingHostContext'; import { useAIModeDocument } from './useAIModeDocument'; import { AI_MODE_NAME } from './copy'; @@ -119,15 +118,6 @@ export function AIModeOverlay() { setDocumentView('document'); }, [noteId]); - // What the conversation set out to write, from its opening message, so the - // details form preselects the matching work type for a note that has none. - const defaultArticleType = useMemo(() => { - const opening = state.chat.chat?.messages.find((message) => message.role === 'user')?.content; - if (!opening) return null; - if (/request for proposals|\bRFP\b/i.test(opening)) return 'grant'; - if (/proposal/i.test(opening)) return 'preregistration'; - return null; - }, [state.chat.chat?.messages]); const openDocument = useCallback(() => setDocumentOpen(true), []); const closeDocument = useCallback(() => setDocumentOpen(false), []); const showDocument = noteId != null && documentOpen; @@ -330,7 +320,6 @@ export function AIModeOverlay() { chat={state.chat.chat} view={documentView} onViewChange={setDocumentView} - defaultArticleType={defaultArticleType} publishControlsSlot={publishControlsSlot} /> @@ -362,7 +351,6 @@ export function AIModeOverlay() { chat={state.chat.chat} view={documentView} onViewChange={setDocumentView} - defaultArticleType={defaultArticleType} presentation="drawer" readOnly className="-mx-4 -mt-2" diff --git a/components/AIMode/ChatPane.tsx b/components/AIMode/ChatPane.tsx index 3c4438bfc..144425788 100644 --- a/components/AIMode/ChatPane.tsx +++ b/components/AIMode/ChatPane.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Menu } from 'lucide-react'; import { ChatComposer } from '@/components/AgentChat/ChatComposer'; import { ChatTranscript } from '@/components/AgentChat/ChatTranscript'; @@ -11,11 +11,10 @@ import { ConversationMenu } from './ConversationMenu'; import { ConversationTitleField } from './ConversationTitleField'; import { Button } from '@/components/ui/Button'; import { ChatTranscriptSkeleton } from '@/components/skeletons/AIModeSkeleton'; -import { Logo } from '@/components/ui/Logo'; import { cn } from '@/utils/styles'; import type { AIModeChatState } from './useAIModeChat'; -import { aiModeGreeting, AI_MODE_STARTER_PROMPTS } from './copy'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { aiModeGreeting, INTENT_COPY } from './copy'; +import { StartScreen } from './start/StartScreen'; import { useUser } from '@/contexts/UserContext'; interface ChatPaneProps { @@ -69,16 +68,7 @@ export function ChatPane({ setRenaming(false); }, [chatId]); - // A starter card is a complete first message: send it and start the - // conversation rather than leaving it in the box to be sent by hand. const { user } = useUser(); - const startFromCard = useCallback( - (message: string) => { - state.clearNotice(); - void state.send(message); - }, - [state] - ); const listBlocked = list.access === 'hidden'; const chatUnavailable = @@ -110,7 +100,7 @@ export function ChatPane({ sendDisabled={state.sendBlocked} notice={notice} className="border-t-0 bg-gray-50" - placeholder="Describe what you want to work on…" + placeholder={chatId == null ? INTENT_COPY[state.intent].placeholder : undefined} toolbar={ ) : chatId == null ? ( - ) : chat.access === 'loading' && chat.chat == null ? ( @@ -249,54 +241,3 @@ function AccessBlocked({ detail }: { readonly detail: string | null }) { ); } - -function EmptyState({ - composer, - greeting, - onSelectStarter, - disabled, -}: { - readonly composer: ReactNode; - readonly greeting: string; - readonly onSelectStarter: (message: string) => void; - readonly disabled: boolean; -}) { - return ( -
-
-
- -
-

{greeting}

-
- -
{composer}
- - {/* Picking a card starts the conversation with its message. */} -
- {AI_MODE_STARTER_PROMPTS.map((prompt) => ( - - ))} -
-
- ); -} diff --git a/components/AIMode/DocumentPane.tsx b/components/AIMode/DocumentPane.tsx index c99494bf3..0db8c753e 100644 --- a/components/AIMode/DocumentPane.tsx +++ b/components/AIMode/DocumentPane.tsx @@ -12,11 +12,7 @@ import { usePublishingCompletion, } from '@/components/Notebook/PublishingForm'; import { ButtonGroup } from '@/components/ui/ButtonGroup'; -import { - PublishingHostProvider, - type PublishingDefaultArticleType, - type PublishingHost, -} from '@/contexts/PublishingHostContext'; +import { PublishingHostProvider, type PublishingHost } from '@/contexts/PublishingHostContext'; import { useNoteDetailsSaver } from '@/hooks/useNoteDetailsSaver'; import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls'; import { noteDiffPersistableDoc } from '@/components/Notebook/NoteReview/noteDiffOverlay'; @@ -48,8 +44,6 @@ interface DocumentPaneProps { readonly chat: AgentChat | null; readonly view: DocumentPaneView; readonly onViewChange: (view: DocumentPaneView) => void; - /** Work type to preselect in the details form for a note without one. */ - readonly defaultArticleType?: PublishingDefaultArticleType | null; /** * A column beside the chat, or the drawer below the tablet breakpoint. The * drawer trades the block strip for a Document | Details switch and keeps @@ -80,7 +74,6 @@ export function DocumentPane({ chat, view, onViewChange, - defaultArticleType = null, presentation = 'pane', publishControlsSlot = null, readOnly = false, @@ -143,9 +136,8 @@ export function DocumentPane({ isLoading: loading, saveDetailsSoon, saveDetailsNow, - defaultArticleType, }), - [content, editor, loading, saveDetailsSoon, saveDetailsNow, defaultArticleType] + [content, editor, loading, saveDetailsSoon, saveDetailsNow] ); const persistEditorState = useCallback(async () => { diff --git a/components/AIMode/copy.ts b/components/AIMode/copy.ts index 93caea86f..7a992a53e 100644 --- a/components/AIMode/copy.ts +++ b/components/AIMode/copy.ts @@ -1,13 +1,8 @@ -import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; -import { - faBullhorn, - faFileSignature, - faMagnifyingGlassDollar, -} from '@fortawesome/pro-light-svg-icons'; +import type { FundingIntent } from '@/components/Funding/fundingDirection'; /** * User-facing copy for AI Mode, in one place so the product name and the - * empty-state wording can change without touching components. + * new-conversation wording can change without touching components. */ export const AI_MODE_NAME = 'ResearchHub AI'; @@ -15,46 +10,14 @@ export const AI_MODE_NAME = 'ResearchHub AI'; export const aiModeGreeting = (firstName: string | null | undefined): string => firstName?.trim() ? `Welcome, ${firstName.trim()}` : 'Welcome'; -export interface StarterPrompt { - readonly id: string; - readonly title: string; - readonly description: string; - /** Same icon family as the sidebar's Publish menu. */ - readonly icon: IconDefinition; - /** Sent as the conversation's first message when the card is picked. */ - readonly message: string; -} - -/** - * Starter cards for the new-conversation screen. Picking one starts the - * conversation with its message; the backend does not supply suggestions. - * Titles and subtext mirror the sidebar's Publish menu. - */ -export const AI_MODE_STARTER_PROMPTS: readonly StarterPrompt[] = [ - { - id: 'draft-rfp', - title: 'Draft a Request for Proposal', - description: 'Fund specific research you care about', - icon: faBullhorn, - message: - 'Help me draft a request for proposals. Ask me for anything you still need to know about ' + - 'the work I want to fund, then create a note and write the RFP into it.', - }, - { - id: 'draft-proposal', - title: 'Draft a Research Proposal', - description: 'Raise money for your research', - icon: faFileSignature, - message: - 'Help me draft a research proposal. Ask me for anything you still need to know about the ' + - 'work, then create a note and write the proposal into it, starting with three hypotheses.', +/** What the intent toggle calls each side, and what the composer asks for. */ +export const INTENT_COPY: Record = { + fund: { + label: 'I want to fund', + placeholder: 'Describe the research you want to fund…', }, - { - id: 'funding', - title: 'Find me funding', - description: 'Open RFPs that fit your work', - icon: faMagnifyingGlassDollar, - message: - 'Find open RFPs I could apply to based on my expertise, and tell me why each one is a match.', + need_funding: { + label: 'I need funding', + placeholder: 'Describe the research you need funding for…', }, -]; +}; diff --git a/components/AIMode/start/IntentCtas.tsx b/components/AIMode/start/IntentCtas.tsx new file mode 100644 index 000000000..32c5b90ba --- /dev/null +++ b/components/AIMode/start/IntentCtas.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; +import { BadgeCheck, Handshake, Target, X, type LucideIcon } from 'lucide-react'; +import { WHITE_GLOVE_BOOKING_URL } from '@/components/Funding/OpenFundingOpportunityModal'; +import type { FundingIntent } from '@/components/Funding/fundingDirection'; +import { SelectFundingOpportunityModal } from '@/components/modals/SelectFundingOpportunityModal'; +import { ProfileEditModal } from '@/components/profile/ProfileEditModal'; +import { useUser } from '@/contexts/UserContext'; +import type { SelectedGrantDetails } from '@/types/grant'; +import { formatCompactAmount } from '@/utils/currency'; + +interface IntentCtasProps { + readonly intent: FundingIntent; + /** The RFP the user means to apply to, chosen before the conversation exists. */ + readonly selectedGrant: SelectedGrantDetails | null; + readonly onSelectGrant: (grant: SelectedGrantDetails | null) => void; +} + +/** + * What sits under the composer on a new conversation: for a researcher, the + * two things that make the assistant's draft theirs — their profile and the + * RFP they are answering; for a funder, a way to the team. + */ +export function IntentCtas({ intent, selectedGrant, onSelectGrant }: IntentCtasProps) { + if (intent === 'fund') { + return ( + + + ); + } + + return ( +
+ + +
+ ); +} + +function ConnectProfileCta() { + const { user } = useUser(); + const [open, setOpen] = useState(false); + const authorId = user?.authorProfile?.id; + const connected = Boolean( + user?.authorProfile?.isOrcidConnected || user?.authorProfile?.googleScholar + ); + + return ( + <> + setOpen(true)} + disabled={authorId == null} + /> + {authorId != null && ( + setOpen(false)} + authorId={authorId} + fields={['social_links']} + socialLinks={['orcid_id', 'google_scholar']} + title="Connect your researcher profile" + description="Add one or both so funders can see your track record." + maxWidth="max-w-lg" + /> + )} + + ); +} + +function ApplyToRfpCta({ + selectedGrant, + onSelectGrant, +}: Pick) { + const [open, setOpen] = useState(false); + + return ( + <> + {selectedGrant ? ( +
+
+ ) : ( + setOpen(true)} + /> + )} + setOpen(false)} + onSelect={onSelectGrant} + /> + + ); +} + +function CtaCard({ + icon: Icon, + title, + description, + onClick, + disabled = false, +}: { + readonly icon: LucideIcon; + readonly title: ReactNode; + readonly description: string; + readonly onClick: () => void; + readonly disabled?: boolean; +}) { + return ( + + ); +} diff --git a/components/AIMode/start/IntentToggle.tsx b/components/AIMode/start/IntentToggle.tsx new file mode 100644 index 000000000..ce912f025 --- /dev/null +++ b/components/AIMode/start/IntentToggle.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { FundingDirectionIcon } from '@/components/Funding/FundingDirectionIcon'; +import { + directionForIntent, + FUNDING_DIRECTION, + type FundingIntent, +} from '@/components/Funding/fundingDirection'; +import { ButtonGroup } from '@/components/ui/ButtonGroup'; +import { INTENT_COPY } from '../copy'; + +const INTENTS: readonly FundingIntent[] = ['fund', 'need_funding']; + +interface IntentToggleProps { + readonly value: FundingIntent; + readonly onChange: (intent: FundingIntent) => void; +} + +/** I want to fund | I need funding — each side in its money's colour. */ +export function IntentToggle({ value, onChange }: IntentToggleProps) { + return ( + onChange(next as FundingIntent)} + className="self-start rounded-[14px]" + options={INTENTS.map((intent) => { + const direction = directionForIntent(intent); + return { + value: intent, + activeClassName: FUNDING_DIRECTION[direction].textClass, + label: ( + <> + + {INTENT_COPY[intent].label} + + ), + }; + })} + /> + ); +} diff --git a/components/AIMode/start/StartScreen.tsx b/components/AIMode/start/StartScreen.tsx new file mode 100644 index 000000000..12abacf05 --- /dev/null +++ b/components/AIMode/start/StartScreen.tsx @@ -0,0 +1,51 @@ +'use client'; + +import type { ReactNode } from 'react'; +import type { FundingIntent } from '@/components/Funding/fundingDirection'; +import { Logo } from '@/components/ui/Logo'; +import type { SelectedGrantDetails } from '@/types/grant'; +import { IntentCtas } from './IntentCtas'; +import { IntentToggle } from './IntentToggle'; + +interface StartScreenProps { + readonly greeting: string; + readonly intent: FundingIntent; + readonly onIntentChange: (intent: FundingIntent) => void; + readonly selectedGrant: SelectedGrantDetails | null; + readonly onSelectGrant: (grant: SelectedGrantDetails | null) => void; + /** The composer, whose first message starts the conversation. */ + readonly composer: ReactNode; +} + +/** + * The new-conversation screen: say whether you are here to fund research or + * to get yours funded, then describe it. The choice fixes what the assistant + * will draft — an RFP or a proposal — before a word is typed. + */ +export function StartScreen({ + greeting, + intent, + onIntentChange, + selectedGrant, + onSelectGrant, + composer, +}: StartScreenProps) { + return ( +
+
+
+ +
+

{greeting}

+
+ +
+ +
+ +
{composer}
+ + +
+ ); +} diff --git a/components/AIMode/start/useFundingIntent.ts b/components/AIMode/start/useFundingIntent.ts new file mode 100644 index 000000000..9d5133676 --- /dev/null +++ b/components/AIMode/start/useFundingIntent.ts @@ -0,0 +1,40 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { FundingIntent } from '@/components/Funding/fundingDirection'; + +const STORAGE_KEY = 'ai-mode:funding-intent'; +/** Most people who open the workspace are researchers. */ +const DEFAULT_INTENT: FundingIntent = 'need_funding'; + +const isIntent = (value: unknown): value is FundingIntent => + value === 'fund' || value === 'need_funding'; + +/** + * The side of the fund / need-funding toggle a new conversation starts on: + * whichever the user picked last, remembered per browser. Read after mount, + * so the server and the first client render agree. + */ +export function useFundingIntent(): [FundingIntent, (intent: FundingIntent) => void] { + const [intent, setIntentState] = useState(DEFAULT_INTENT); + + useEffect(() => { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (isIntent(stored)) setIntentState(stored); + } catch { + // Storage may be unavailable; the default stands. + } + }, []); + + const setIntent = useCallback((next: FundingIntent) => { + setIntentState(next); + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + // Not remembered this time; the choice still applies to this session. + } + }, []); + + return [intent, setIntent]; +} diff --git a/components/AIMode/useAIModeChat.ts b/components/AIMode/useAIModeChat.ts index aed21fc42..c2d0393ee 100644 --- a/components/AIMode/useAIModeChat.ts +++ b/components/AIMode/useAIModeChat.ts @@ -6,7 +6,10 @@ import { getChatTransport } from '@/services/chatTransport'; import { useAgentChatList, type UseAgentChatListResult } from '@/hooks/useAgentChat'; import { useChatSession, type ChatSession } from '@/hooks/useChatSession'; import type { ChatNoticePolicy } from '@/components/AgentChat/chatNotices'; +import type { FundingIntent } from '@/components/Funding/fundingDirection'; import type { ChatNoteRef, AgentChat } from '@/types/agentChat'; +import type { SelectedGrantDetails } from '@/types/grant'; +import { useFundingIntent } from './start/useFundingIntent'; /** Matches the chat hook's own poll cadence, so a background turn's spinner clears as fast as the open one. */ const LIST_POLL_INTERVAL_MS = 5000; @@ -16,6 +19,12 @@ const NOTICES: ChatNoticePolicy = { noun: 'conversation', usageLimit: 'inline' } export interface AIModeChatState extends ChatSession { readonly chatId: number | null; readonly list: UseAgentChatListResult; + /** What the next conversation is for; sent with its creation. */ + readonly intent: FundingIntent; + readonly setIntent: (intent: FundingIntent) => void; + /** The RFP the next conversation's proposal answers, chosen on the start screen. */ + readonly selectedGrant: SelectedGrantDetails | null; + readonly setSelectedGrant: (grant: SelectedGrantDetails | null) => void; /** Rename any conversation, open or not. */ readonly rename: (chatId: number, title: string) => Promise; /** @@ -53,8 +62,23 @@ export function useAIModeChat(): AIModeChatState { const list = useAgentChatList(transport, true); const refreshList = list.refresh; + + // ---- what the next conversation starts out knowing ---- + const [intent, setIntent] = useFundingIntent(); + const [selectedGrant, setSelectedGrant] = useState(null); + const createInitRef = useRef({ intent, selectedGrant }); + createInitRef.current = { intent, selectedGrant }; + const getCreateInit = useCallback(() => { + const { intent: current, selectedGrant: grant } = createInitRef.current; + return { intent: current, selectedGrantId: grant?.id ?? null }; + }, []); + const onChatCreated = useCallback( - (created: AgentChat) => selectChatInUrl(created.conversation_id), + (created: AgentChat) => { + // The RFP went with the conversation it was picked for. + setSelectedGrant(null); + selectChatInUrl(created.conversation_id); + }, [selectChatInUrl] ); const session = useChatSession({ @@ -63,6 +87,7 @@ export function useAIModeChat(): AIModeChatState { enabled: true, onChatCreated, onListStale: refreshList, + getCreateInit, notices: NOTICES, }); const { chat } = session; @@ -191,6 +216,10 @@ export function useAIModeChat(): AIModeChatState { ...session, chatId, list, + intent, + setIntent, + selectedGrant, + setSelectedGrant, rename, deleteChat, notesForChat, diff --git a/components/Funding/FundingDirectionIcon.tsx b/components/Funding/FundingDirectionIcon.tsx new file mode 100644 index 000000000..2e477b78d --- /dev/null +++ b/components/Funding/FundingDirectionIcon.tsx @@ -0,0 +1,35 @@ +import { cn } from '@/utils/styles'; +import { FUNDING_DIRECTION, type FundingDirection } from './fundingDirection'; + +interface FundingDirectionIconProps { + readonly direction: FundingDirection; + /** + * Coloured by direction, or neutral: a grey circle whose arrow inherits + * the text colour around it, for a place that colours itself (a tab). + */ + readonly colored?: boolean; + readonly className?: string; + readonly iconClassName?: string; +} + +/** The direction's arrow in a circle. */ +export function FundingDirectionIcon({ + direction, + colored = true, + className, + iconClassName, +}: FundingDirectionIconProps) { + const { icon: Icon, iconClass, chipClass } = FUNDING_DIRECTION[direction]; + return ( + + ); +} diff --git a/components/Funding/OpenFundingOpportunityModal.tsx b/components/Funding/OpenFundingOpportunityModal.tsx index 04ed020a6..f6bf9bf1e 100644 --- a/components/Funding/OpenFundingOpportunityModal.tsx +++ b/components/Funding/OpenFundingOpportunityModal.tsx @@ -102,7 +102,8 @@ const CREATION_OPTIONS: CreationOption[] = [ }, ]; -const WHITE_GLOVE_BOOKING_URL = 'https://cal.com/tyler-diorio/15min'; +/** Where a funder books time with the ResearchHub team to shape an RFP together. */ +export const WHITE_GLOVE_BOOKING_URL = 'https://cal.com/tyler-diorio/15min'; type Step = 'benefits' | 'method' | 'upload'; diff --git a/components/Funding/fundingDirection.ts b/components/Funding/fundingDirection.ts new file mode 100644 index 000000000..36fb10c26 --- /dev/null +++ b/components/Funding/fundingDirection.ts @@ -0,0 +1,41 @@ +import { ArrowDownLeft, ArrowUpRight, type LucideIcon } from 'lucide-react'; + +/** + * Which way money moves for the person looking at it: out of their hands + * when they fund research, into them when their research is funded. One + * visual language for it everywhere — the My Funding tabs, the workspace's + * intent toggle, a document's kind in a list. + */ +export type FundingDirection = 'giving' | 'receiving'; + +/** What a workspace conversation is for; the backend stores the same values. */ +export type FundingIntent = 'fund' | 'need_funding'; + +export interface FundingDirectionStyle { + readonly icon: LucideIcon; + /** The arrow, when it is coloured rather than inheriting. */ + readonly iconClass: string; + /** The circle behind the arrow, when coloured. */ + readonly chipClass: string; + /** Text that takes the direction's colour, e.g. an active toggle label. */ + readonly textClass: string; +} + +/** Money out reads blue, the brand's default; money in reads emerald. */ +export const FUNDING_DIRECTION: Record = { + giving: { + icon: ArrowUpRight, + iconClass: 'text-primary-700', + chipClass: 'bg-primary-100', + textClass: 'text-primary-700', + }, + receiving: { + icon: ArrowDownLeft, + iconClass: 'text-emerald-700', + chipClass: 'bg-emerald-100', + textClass: 'text-emerald-700', + }, +}; + +export const directionForIntent = (intent: FundingIntent): FundingDirection => + intent === 'fund' ? 'giving' : 'receiving'; diff --git a/components/Notebook/PublishingForm/PublishingFormProvider.tsx b/components/Notebook/PublishingForm/PublishingFormProvider.tsx index 91ebc892c..090c12721 100644 --- a/components/Notebook/PublishingForm/PublishingFormProvider.tsx +++ b/components/Notebook/PublishingForm/PublishingFormProvider.tsx @@ -69,7 +69,7 @@ export function PublishingFormProvider({ readOnly = false, children, }: PublishingFormProviderProps) { - const { note, editor, saveDetailsSoon, saveDetailsNow, defaultArticleType } = usePublishingHost(); + const { note, editor, saveDetailsSoon, saveDetailsNow } = usePublishingHost(); const { user: currentUser } = useUser(); const searchParams = useSearchParams(); @@ -100,7 +100,6 @@ export function PublishingFormProvider({ const articleType = (note.documentType ? mapDocumentTypeToArticleType(note.documentType) : null) ?? resolveArticleType(searchParams) ?? - defaultArticleType ?? null; if (articleType) { diff --git a/components/profile/About/ProfileInformationForm/index.tsx b/components/profile/About/ProfileInformationForm/index.tsx index 308e6087b..8679e04d0 100644 --- a/components/profile/About/ProfileInformationForm/index.tsx +++ b/components/profile/About/ProfileInformationForm/index.tsx @@ -27,9 +27,13 @@ import { faUpload, faGraduationCap, faShareNodes, faUser } from '@fortawesome/pr import Icon from '@/components/ui/icons/Icon'; import { OrcidConnectButton } from '@/components/Orcid/OrcidConnectButton'; +export type SocialLinkKey = 'linkedin' | 'google_scholar' | 'twitter' | 'orcid_id'; + interface ProfileInformationFormProps { onSubmit: (data: ProfileInformationFormValues) => void; fields?: FormField[]; + /** Which of the social links to show; all of them by default. */ + socialLinks?: readonly SocialLinkKey[]; formId?: string; showAvatar?: boolean; useAccordion?: boolean; @@ -39,6 +43,7 @@ interface ProfileInformationFormProps { export function ProfileInformationForm({ onSubmit, fields = ALL_PROFILE_FIELDS, + socialLinks: shownSocialLinks, formId, showAvatar = true, useAccordion = false, @@ -248,40 +253,42 @@ export function ProfileInformationForm({ const socialContent = (
- {(Object.keys(socialLinkMeta) as Array).map((key) => { - const meta = socialLinkMeta[key]; - const isOrcid = key === 'orcid_id'; - - return ( -
- - {isOrcid && !isOrcidConnected ? ( - shownSocialLinks?.includes(key) ?? true) + .map((key) => { + const meta = socialLinkMeta[key]; + const isOrcid = key === 'orcid_id'; + + return ( +
+ - ) : ( - - )} -
- ); - })} + {isOrcid && !isOrcidConnected ? ( + + ) : ( + + )} +
+ ); + })}
); diff --git a/components/profile/ProfileEditModal.tsx b/components/profile/ProfileEditModal.tsx index 2c52d0199..7fe030025 100644 --- a/components/profile/ProfileEditModal.tsx +++ b/components/profile/ProfileEditModal.tsx @@ -4,8 +4,15 @@ import { useId } from 'react'; import { toast } from 'react-hot-toast'; import { BaseModal } from '@/components/ui/BaseModal'; import { Button } from '@/components/ui/Button'; -import { ProfileInformationForm } from '@/components/profile/About/ProfileInformationForm'; -import { ProfileInformationFormValues } from '@/components/profile/About/ProfileInformationForm/schema'; +import { + ProfileInformationForm, + type SocialLinkKey, +} from '@/components/profile/About/ProfileInformationForm'; +import { + ALL_PROFILE_FIELDS, + type FormField, + type ProfileInformationFormValues, +} from '@/components/profile/About/ProfileInformationForm/schema'; import { useUpdateAuthorProfileData } from '@/hooks/useAuthor'; import { useUser } from '@/contexts/UserContext'; @@ -13,18 +20,32 @@ interface ProfileEditModalProps { isOpen: boolean; onClose: () => void; authorId: number; - refetchAuthorInfo: () => Promise; + /** Reload whatever the host shows of the profile; the signed-in user is refreshed regardless. */ + refetchAuthorInfo?: () => Promise; + /** A subset of the profile to edit; the whole profile by default. */ + fields?: FormField[]; + socialLinks?: readonly SocialLinkKey[]; + title?: string; + description?: string; + maxWidth?: string; } +/** Edits the signed-in user's author profile, or the part of it the host names. */ export function ProfileEditModal({ isOpen, onClose, authorId, refetchAuthorInfo, + fields = ALL_PROFILE_FIELDS, + socialLinks, + title = 'Edit Profile', + description, + maxWidth = 'max-w-5xl', }: ProfileEditModalProps) { const formId = useId(); const { refreshUser } = useUser(); const [{ isLoading }, updateAuthorProfileData] = useUpdateAuthorProfileData(); + const wholeProfile = fields === ALL_PROFILE_FIELDS; const handleSubmit = async (data: ProfileInformationFormValues) => { if (!authorId) { @@ -44,7 +65,7 @@ export function ProfileEditModal({ google_scholar: data.google_scholar, }); - await refetchAuthorInfo(); + await refetchAuthorInfo?.(); toast.success('Profile updated successfully'); onClose(); await refreshUser(); @@ -57,15 +78,23 @@ export function ProfileEditModal({ {isLoading ? 'Saving...' : 'Save Changes'} } > - + {description &&

{description}

} +
); } diff --git a/components/ui/ButtonGroup.tsx b/components/ui/ButtonGroup.tsx index cb4ba8240..932a67fbb 100644 --- a/components/ui/ButtonGroup.tsx +++ b/components/ui/ButtonGroup.tsx @@ -7,6 +7,8 @@ interface ButtonGroupOption { value: string; label: ReactNode; badge?: string | number; + /** Extra classes while this option is the active one, e.g. its own colour. */ + activeClassName?: string; } interface ButtonGroupProps { @@ -90,8 +92,12 @@ export function ButtonGroup({ {options.map((option) => (