11'use client' ;
22
3- import { useState , useEffect , useRef } from 'react' ;
3+ import { useEffect , useRef } from 'react' ;
44import { motion , AnimatePresence } from 'framer-motion' ;
55import {
66 Check ,
@@ -13,10 +13,9 @@ import {
1313 Smartphone ,
1414 X ,
1515} from 'lucide-react' ;
16- import { toPng } from 'html-to-image' ;
1716import type { DashboardExportData } from '@/types/dashboard' ;
17+ import { useShareActions } from '@/hooks/useShareActions' ;
1818
19- // Inline branded icons (Twitter/X brand, LinkedIn brand)
2019const XBrandIcon = ( { size = 18 , className = '' } : { size ?: number ; className ?: string } ) => (
2120 < svg width = { size } height = { size } viewBox = "0 0 24 24" fill = "currentColor" className = { className } >
2221 < path d = "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25Zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
@@ -42,191 +41,34 @@ interface ShareSheetProps {
4241 exportData : DashboardExportData ;
4342}
4443
45- type OptionState = 'idle' | 'loading' | 'success' | 'error' ;
46-
47- const PROFILE_URL = ( username : string ) =>
48- typeof window !== 'undefined'
49- ? `${ window . location . origin } /dashboard/${ username } `
50- : `https://commitpulse.vercel.app/dashboard/${ username } ` ;
51-
5244export default function ShareSheet ( { username, isOpen, onClose, exportData } : ShareSheetProps ) {
53- const [ states , setStates ] = useState < Record < string , OptionState > > ( { } ) ;
5445 const overlayRef = useRef < HTMLDivElement > ( null ) ;
46+ const {
47+ states,
48+ handleCopyLink,
49+ handleTwitter,
50+ handleLinkedIn,
51+ handleReddit,
52+ handleDownloadPNG,
53+ handleDownloadSVG,
54+ handleCopyMarkdown,
55+ handleDownloadJSON,
56+ handleNativeShare,
57+ } = useShareActions ( username , exportData , onClose ) ;
5558
56- // Close on Escape
5759 useEffect ( ( ) => {
5860 const handler = ( e : KeyboardEvent ) => e . key === 'Escape' && onClose ( ) ;
5961 window . addEventListener ( 'keydown' , handler ) ;
6062 return ( ) => window . removeEventListener ( 'keydown' , handler ) ;
6163 } , [ onClose ] ) ;
6264
63- // Prevent scroll when open
6465 useEffect ( ( ) => {
6566 document . body . style . overflow = isOpen ? 'hidden' : '' ;
6667 return ( ) => {
6768 document . body . style . overflow = '' ;
6869 } ;
6970 } , [ isOpen ] ) ;
7071
71- const setOptionState = ( key : string , state : OptionState ) => {
72- setStates ( ( prev ) => ( { ...prev , [ key ] : state } ) ) ;
73- if ( state === 'success' || state === 'error' ) {
74- setTimeout ( ( ) => setStates ( ( prev ) => ( { ...prev , [ key ] : 'idle' } ) ) , 2500 ) ;
75- }
76- } ;
77-
78- /* ── Option handlers ─────────────────────────── */
79-
80- const handleCopyLink = async ( ) => {
81- setOptionState ( 'copy' , 'loading' ) ;
82- try {
83- await navigator . clipboard . writeText ( PROFILE_URL ( username ) ) ;
84- setOptionState ( 'copy' , 'success' ) ;
85- setTimeout ( ( ) => onClose ( ) , 800 ) ;
86- } catch {
87- setOptionState ( 'copy' , 'error' ) ;
88- }
89- } ;
90-
91- const handleTwitter = ( ) => {
92- const url = PROFILE_URL ( username ) ;
93- const text = encodeURIComponent ( `Check out my GitHub commit pulse on CommitPulse 🚀\n${ url } ` ) ;
94- window . open ( `https://twitter.com/intent/tweet?text=${ text } ` , '_blank' , 'noopener' ) ;
95- onClose ( ) ;
96- } ;
97-
98- const handleLinkedIn = ( ) => {
99- const url = encodeURIComponent ( PROFILE_URL ( username ) ) ;
100- window . open ( `https://www.linkedin.com/sharing/share-offsite/?url=${ url } ` , '_blank' , 'noopener' ) ;
101- onClose ( ) ;
102- } ;
103-
104- const handleReddit = ( ) => {
105- const url = encodeURIComponent ( PROFILE_URL ( username ) ) ;
106- const title = encodeURIComponent ( 'Check out my CommitPulse dashboard 🚀' ) ;
107-
108- window . open ( `https://www.reddit.com/submit?url=${ url } &title=${ title } ` , '_blank' ) ;
109-
110- onClose ( ) ;
111- } ;
112-
113- const handleDownloadPNG = async ( ) => {
114- setOptionState ( 'png' , 'loading' ) ;
115- try {
116- // Target the whole dashboard wrapper; fall back to body
117- const node =
118- document . getElementById ( 'dashboard-root' ) ??
119- document . querySelector < HTMLElement > ( '[data-dashboard]' ) ??
120- document . body ;
121-
122- const dataUrl = await toPng ( node , {
123- quality : 0.95 ,
124- pixelRatio : 2 ,
125- backgroundColor : '#050505' ,
126- filter : ( el ) => {
127- // Exclude the share sheet itself and the generate button from the capture
128- if ( el instanceof HTMLElement ) {
129- if ( el . id === 'share-sheet-overlay' ) return false ;
130- if ( el . id === 'generate-dashboard-btn' ) return false ;
131- }
132- return true ;
133- } ,
134- } ) ;
135-
136- const link = document . createElement ( 'a' ) ;
137- link . download = `${ username } -commitpulse.png` ;
138- link . href = dataUrl ;
139- link . click ( ) ;
140- setOptionState ( 'png' , 'success' ) ;
141- } catch {
142- setOptionState ( 'png' , 'error' ) ;
143- }
144- } ;
145- const handleDownloadSVG = async ( ) => {
146- setOptionState ( 'svg' , 'loading' ) ;
147- try {
148- const response = await fetch ( `/api/streak?user=${ encodeURIComponent ( username ) } ` ) ;
149- if ( ! response . ok ) throw new Error ( 'Failed to fetch SVG' ) ;
150- const svgText = await response . text ( ) ;
151- const blob = new Blob ( [ svgText ] , { type : 'image/svg+xml' } ) ;
152- const url = URL . createObjectURL ( blob ) ;
153- const link = document . createElement ( 'a' ) ;
154- link . download = `${ username } -commitpulse.svg` ;
155- link . href = url ;
156- link . click ( ) ;
157- URL . revokeObjectURL ( url ) ;
158- setOptionState ( 'svg' , 'success' ) ;
159- } catch {
160- setOptionState ( 'svg' , 'error' ) ;
161- }
162- } ;
163-
164- const handleCopyMarkdown = async ( ) => {
165- setOptionState ( 'markdown' , 'loading' ) ;
166- try {
167- const markdown = ` } )` ;
168- await navigator . clipboard . writeText ( markdown ) ;
169- setOptionState ( 'markdown' , 'success' ) ;
170- setTimeout ( ( ) => onClose ( ) , 800 ) ;
171- } catch {
172- setOptionState ( 'markdown' , 'error' ) ;
173- }
174- } ;
175-
176- const handleDownloadJSON = ( ) => {
177- setOptionState ( 'json' , 'loading' ) ;
178- try {
179- const payload = {
180- username,
181- profileUrl : PROFILE_URL ( username ) ,
182- exportedAt : new Date ( ) . toISOString ( ) ,
183- currentStreak : exportData . stats . currentStreak ,
184- longestStreak : exportData . stats . peakStreak ,
185- totalContributions : exportData . stats . totalContributions ,
186- topLanguages : exportData . languages ,
187- } ;
188- const blob = new Blob ( [ JSON . stringify ( payload , null , 2 ) ] , {
189- type : 'application/json' ,
190- } ) ;
191- const url = URL . createObjectURL ( blob ) ;
192- const link = document . createElement ( 'a' ) ;
193- link . download = `commitpulse-${ username } .json` ;
194- link . href = url ;
195- link . click ( ) ;
196- URL . revokeObjectURL ( url ) ;
197- setOptionState ( 'json' , 'success' ) ;
198- } catch {
199- setOptionState ( 'json' , 'error' ) ;
200- }
201- } ;
202-
203- const handleNativeShare = async ( ) => {
204- if ( ! ( 'share' in navigator ) ) {
205- // Graceful fallback: just copy the link
206- await handleCopyLink ( ) ;
207- return ;
208- }
209- setOptionState ( 'native' , 'loading' ) ;
210- try {
211- await navigator . share ( {
212- title : `${ username } 's Commit Pulse` ,
213- text : `Check out my GitHub contribution pulse — streaks, insights, and more.` ,
214- url : PROFILE_URL ( username ) ,
215- } ) ;
216- setOptionState ( 'native' , 'success' ) ;
217- setTimeout ( ( ) => onClose ( ) , 600 ) ;
218- } catch ( err ) {
219- // User cancelled — not an error worth showing
220- if ( err instanceof Error && err . name !== 'AbortError' ) {
221- setOptionState ( 'native' , 'error' ) ;
222- } else {
223- setOptionState ( 'native' , 'idle' ) ;
224- }
225- }
226- } ;
227-
228- /* ── Option config ───────────────────────────── */
229-
23072 const options = [
23173 {
23274 key : 'copy' ,
@@ -255,7 +97,6 @@ export default function ShareSheet({ username, isOpen, onClose, exportData }: Sh
25597 glow : 'rgba(37,99,235,0.35)' ,
25698 action : handleLinkedIn ,
25799 } ,
258-
259100 {
260101 key : 'markdown' ,
261102 icon : Code ,
@@ -309,20 +150,19 @@ export default function ShareSheet({ username, isOpen, onClose, exportData }: Sh
309150 } ,
310151 {
311152 key : 'reddit' ,
153+ icon : RedditIcon ,
312154 label : 'Reddit' ,
313155 description : 'Share on Reddit' ,
314- icon : RedditIcon ,
315- action : handleReddit ,
316156 gradient : 'from-orange-500 to-orange-700' ,
317157 glow : 'rgba(249,115,22,0.35)' ,
158+ action : handleReddit ,
318159 } ,
319160 ] ;
320161
321162 return (
322163 < AnimatePresence >
323164 { isOpen && (
324165 < >
325- { /* Backdrop */ }
326166 < motion . div
327167 id = "share-sheet-overlay"
328168 ref = { overlayRef }
@@ -333,7 +173,6 @@ export default function ShareSheet({ username, isOpen, onClose, exportData }: Sh
333173 onClick = { onClose }
334174 className = "fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px] flex items-center justify-center p-4"
335175 >
336- { /* Panel */ }
337176 < motion . div
338177 initial = { { opacity : 0 , y : 16 , scale : 0.98 } }
339178 animate = { { opacity : 1 , y : 0 , scale : 1 } }
@@ -342,8 +181,7 @@ export default function ShareSheet({ username, isOpen, onClose, exportData }: Sh
342181 className = "relative w-full max-w-sm"
343182 onClick = { ( e ) => e . stopPropagation ( ) }
344183 >
345- < div className = "rounded-xl bg-white/60 dark:bg-white/[0.05] dark:bg-white/[0.05] backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-[0_24px_64px_rgba(0,0,0,0.18)] dark:shadow-[0_24px_64px_rgba(0,0,0,0.7)] overflow-hidden" >
346- { /* Header */ }
184+ < div className = "rounded-xl bg-white/60 dark:bg-white/[0.05] backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-[0_24px_64px_rgba(0,0,0,0.18)] dark:shadow-[0_24px_64px_rgba(0,0,0,0.7)] overflow-hidden" >
347185 < div className = "flex items-center justify-between px-5 pt-5 pb-4 border-b border-white/10" >
348186 < div >
349187 < h2 className = "text-sm font-semibold text-gray-900 dark:text-white tracking-tight" >
@@ -359,13 +197,10 @@ export default function ShareSheet({ username, isOpen, onClose, exportData }: Sh
359197 < X size = { 14 } className = "text-gray-500 dark:text-white/45" />
360198 </ button >
361199 </ div >
362-
363- { /* Options */ }
364200 < div className = "flex flex-col p-3 gap-1" >
365201 { options . map ( ( opt , idx ) => {
366202 const state = states [ opt . key ] ?? 'idle' ;
367203 const Icon = opt . icon ;
368-
369204 return (
370205 < motion . button
371206 key = { opt . key }
@@ -376,7 +211,6 @@ export default function ShareSheet({ username, isOpen, onClose, exportData }: Sh
376211 disabled = { state === 'loading' }
377212 className = "group flex items-center gap-3 w-full px-3 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-white/[0.06] border border-transparent hover:border-white/10 transition-all duration-200 text-left disabled:opacity-40 disabled:cursor-not-allowed"
378213 >
379- { /* Icon box */ }
380214 < div className = "flex-shrink-0 w-8 h-8 rounded-lg bg-gray-100 dark:bg-white/[0.04] border border-[rgba(255,255,255,0.08)] flex items-center justify-center" >
381215 { state === 'loading' ? (
382216 < Loader2
@@ -392,8 +226,6 @@ export default function ShareSheet({ username, isOpen, onClose, exportData }: Sh
392226 />
393227 ) }
394228 </ div >
395-
396- { /* Label */ }
397229 < div className = "flex flex-col min-w-0" >
398230 < span className = "text-sm text-gray-900 dark:text-white font-medium leading-tight" >
399231 { state === 'success'
0 commit comments