Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
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}