Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 131 lines 4.9 kB view raw
1'use client'; 2 3import { useState, type ComponentProps } from 'react'; 4import { useTranslations } from 'next-intl'; 5import ReactMarkdown from 'react-markdown'; 6import remarkGfm from 'remark-gfm'; 7import { PencilSimple } from '@phosphor-icons/react'; 8import { sanitize } from '@/lib/sanitize'; 9import { cn } from '@/lib/utils'; 10import { Button } from '@/components/ui/button'; 11import { ProfileEditDialog } from '@/components/profile-edit-dialog'; 12import { useProfileEdit } from '@/components/profile-edit-provider'; 13 14const COLLAPSE_THRESHOLD = 300; 15 16interface AboutSectionProps { 17 about: string; 18 isOwnProfile?: boolean; 19} 20 21/** Open links in new tab with safe rel attributes. */ 22function MarkdownLink(props: ComponentProps<'a'>) { 23 // eslint-disable-next-line jsx-a11y/anchor-has-content -- children are passed via spread props from react-markdown 24 return <a {...props} target="_blank" rel="noopener noreferrer" />; 25} 26 27export function AboutSection({ about, isOwnProfile }: AboutSectionProps) { 28 const t = useTranslations('profile'); 29 const [expanded, setExpanded] = useState(false); 30 const [editing, setEditing] = useState(false); 31 const { profile } = useProfileEdit(); 32 33 if (!about && !isOwnProfile) return null; 34 35 if (!about && isOwnProfile) { 36 return ( 37 <section> 38 <div className="flex items-center gap-2"> 39 <p className="text-sm text-muted-foreground">{t('addAbout')}</p> 40 <Button 41 variant="ghost" 42 size="sm" 43 className="h-7 w-7 p-0" 44 onClick={() => setEditing(true)} 45 aria-label={t('editAbout')} 46 > 47 <PencilSimple className="h-3.5 w-3.5" weight="bold" aria-hidden="true" /> 48 </Button> 49 </div> 50 {editing && ( 51 <ProfileEditDialog 52 handle={profile.handle} 53 did={profile.did} 54 displayName={profile.displayName} 55 avatar={profile.avatar} 56 headline={profile.headline} 57 about={profile.about} 58 location={profile.location} 59 openTo={profile.openTo} 60 preferredWorkplace={profile.preferredWorkplace} 61 hasDisplayNameOverride={profile.hasDisplayNameOverride} 62 hasAvatarUrlOverride={profile.hasAvatarUrlOverride} 63 sourceDisplayName={profile.source?.displayName} 64 sourceAvatar={profile.source?.avatarUrl} 65 onClose={() => setEditing(false)} 66 /> 67 )} 68 </section> 69 ); 70 } 71 72 // Strip any raw HTML from the source before markdown rendering 73 const cleaned = sanitize(about); 74 const isLong = cleaned.length > COLLAPSE_THRESHOLD; 75 76 return ( 77 <section aria-label={t('about')}> 78 <div className="group/about relative"> 79 <div 80 className={cn( 81 'prose prose-sm dark:prose-invert max-w-none overflow-hidden text-base leading-relaxed text-foreground transition-[max-height] duration-200 ease-in-out prose-a:text-primary prose-a:underline prose-a:underline-offset-4', 82 isLong && !expanded && 'max-h-[4.5rem]', 83 isLong && expanded && 'max-h-[200rem]', 84 )} 85 > 86 <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: MarkdownLink }}> 87 {cleaned} 88 </ReactMarkdown> 89 </div> 90 {isOwnProfile && ( 91 <Button 92 variant="ghost" 93 size="sm" 94 className="absolute -right-2 top-0 h-7 w-7 p-0 opacity-0 transition-opacity group-hover/about:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-60" 95 onClick={() => setEditing(true)} 96 aria-label={t('editAbout')} 97 > 98 <PencilSimple className="h-3.5 w-3.5" weight="bold" aria-hidden="true" /> 99 </Button> 100 )} 101 </div> 102 {isLong && ( 103 <button 104 type="button" 105 onClick={() => setExpanded(!expanded)} 106 className="mt-2 text-sm font-semibold text-primary underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" 107 > 108 {expanded ? t('readLess') : t('readMore')} 109 </button> 110 )} 111 {editing && ( 112 <ProfileEditDialog 113 handle={profile.handle} 114 did={profile.did} 115 displayName={profile.displayName} 116 avatar={profile.avatar} 117 headline={profile.headline} 118 about={profile.about} 119 location={profile.location} 120 openTo={profile.openTo} 121 preferredWorkplace={profile.preferredWorkplace} 122 hasDisplayNameOverride={profile.hasDisplayNameOverride} 123 hasAvatarUrlOverride={profile.hasAvatarUrlOverride} 124 sourceDisplayName={profile.source?.displayName} 125 sourceAvatar={profile.source?.avatarUrl} 126 onClose={() => setEditing(false)} 127 /> 128 )} 129 </section> 130 ); 131}