diff --git a/src/components/CaseStudyDrawer.module.css b/src/components/CaseStudyDrawer.module.css index 607fe7e..ff371c1 100644 --- a/src/components/CaseStudyDrawer.module.css +++ b/src/components/CaseStudyDrawer.module.css @@ -528,6 +528,163 @@ border-color: rgba(255, 255, 255, 0.16); } +.shareDialog { + position: fixed; + inset: 0; + display: grid; + width: 100vw; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + padding: var(--space-4); + place-items: center; + overflow: hidden; + border: 0; + background: transparent; + color: var(--text-primary); +} + +.shareDialog:not([open]) { + display: none; +} + +.shareDialog::backdrop { + background: rgba(1, 13, 39, 0.62); + opacity: 0; + backdrop-filter: blur(4px); + transition: opacity 180ms var(--motion-ease-out); +} + +.shareDialog[data-state='open']::backdrop { + opacity: 1; +} + +.shareDialog[data-state='closing']::backdrop { + opacity: 0; + transition-duration: 140ms; +} + +.sharePanel { + width: min(30rem, 100%); + padding: var(--space-5); + border: 1px solid var(--border-strong); + border-radius: var(--radius-xl); + background: var(--bg-raised); + box-shadow: 0 1.5rem 5rem rgba(1, 13, 39, 0.3); + opacity: 0; + transform: scale(0.97) translateY(0.5rem); + transition: + opacity 180ms var(--motion-ease-out), + transform 180ms var(--motion-ease-out); +} + +.shareDialog[data-state='open'] .sharePanel { + opacity: 1; + transform: scale(1) translateY(0); +} + +.shareDialog[data-state='closing'] .sharePanel { + opacity: 0; + transform: scale(0.98) translateY(0.25rem); + transition-duration: 140ms; +} + +.shareDialog[data-motion='none'] .sharePanel, +.shareDialog[data-motion='none']::backdrop { + transition-duration: 0ms; +} + +.shareHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--border-default); +} + +.shareHeader h2 { + margin-bottom: var(--space-1); + color: var(--text-primary); + font-size: var(--font-size-h3); + line-height: var(--line-height-h3); +} + +.shareHeader p { + max-width: 22rem; + color: var(--text-secondary); + font-size: var(--font-size-body-small); + line-height: var(--line-height-body-small); +} + +.shareOptions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-3); + margin-block: var(--space-5); +} + +.shareOptions a { + display: flex; + min-width: 0; + min-height: 5rem; + align-items: center; + justify-content: center; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-2); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--bg-surface); + color: var(--text-primary); + font-size: var(--font-size-label-small); + font-weight: 650; + text-align: center; + transition: + border-color var(--motion-duration-fast) ease, + background-color var(--motion-duration-fast) ease, + transform var(--motion-duration-fast) var(--motion-ease-out); +} + +.shareOptions svg, +.copyLinkButton svg { + width: var(--size-icon-md); + height: var(--size-icon-md); +} + +.copyLinkButton { + display: flex; + width: 100%; + min-height: 3rem; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-surface); + color: var(--text-primary); + cursor: pointer; + font: inherit; + font-weight: 700; + transition: + border-color var(--motion-duration-fast) ease, + background-color var(--motion-duration-fast) ease, + transform var(--motion-duration-fast) var(--motion-ease-out); +} + +.galleryStage:focus-visible, +.thumbnailButton:focus-visible, +.viewerThumbnail:focus-visible, +.viewerIconButton:focus-visible, +.viewerNavButton:focus-visible, +.shareOptions a:focus-visible, +.copyLinkButton:focus-visible { + outline: 3px solid var(--focus-ring, var(--accent-default)); + outline-offset: 3px; +} + .galleryStage:active, .thumbnailButton:active, .viewerThumbnail:active, @@ -535,6 +692,11 @@ transform: scale(0.97); } +.shareOptions a:active, +.copyLinkButton:active { + transform: scale(0.97); +} + .storyGrid, .stackGrid { display: grid; @@ -780,6 +942,14 @@ justify-content: flex-start; padding-inline: var(--space-3); } + + .sharePanel { + padding: var(--space-4); + } + + .shareOptions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (hover: hover) and (pointer: fine) { @@ -798,6 +968,12 @@ opacity: 1; } + .shareOptions a:hover, + .copyLinkButton:hover { + border-color: var(--accent-default); + background: var(--bg-subtle); + } + .projectNavLink:hover { transform: translateY(-1px); } @@ -822,6 +998,16 @@ transition-duration: 120ms; } + .sharePanel, + .shareDialog::backdrop { + transition-duration: 120ms; + } + + .sharePanel, + .shareDialog[data-state='closing'] .sharePanel { + transform: none; + } + .thumbnailRail { scroll-behavior: auto; } diff --git a/src/components/CaseStudyDrawer.tsx b/src/components/CaseStudyDrawer.tsx index 9d6fb3c..2029e5c 100644 --- a/src/components/CaseStudyDrawer.tsx +++ b/src/components/CaseStudyDrawer.tsx @@ -1,15 +1,14 @@ 'use client'; -import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react'; -import { Check, Copy, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, type PointerEvent, type ReactNode } from 'react'; +import { X } from 'lucide-react'; import { usePathname } from 'next/navigation'; import { messagesByLocale, type Locale } from '@/i18n/content'; import { useDismissCaseStudy } from './PortfolioRouteShell'; +import { CaseStudyShare } from './CaseStudyShare'; import { useDrawerDrag } from './useDrawerDrag'; import styles from './CaseStudyDrawer.module.css'; -type CopyState = 'idle' | 'copied' | 'error'; - export function CaseStudyDrawer({ children, locale }: { children: ReactNode; locale: Locale }) { const copy = messagesByLocale[locale].projects; const pathname = usePathname(); @@ -21,7 +20,6 @@ export function CaseStudyDrawer({ children, locale }: { children: ReactNode; loc const closing = useRef(false); const returnFocusRef = useRef(null); const backgroundScrollY = useRef(0); - const [copyState, setCopyState] = useState('idle'); const finishDismiss = useCallback(() => { const dialog = dialogRef.current; @@ -95,20 +93,10 @@ export function CaseStudyDrawer({ children, locale }: { children: ReactNode; loc useEffect(() => { scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' }); requestAnimationFrame(() => { - setCopyState('idle'); dialogRef.current?.querySelector('[data-case-study-initial-focus]')?.focus(); }); }, [pathname]); - const copyCurrentLink = async () => { - try { - await navigator.clipboard.writeText(window.location.href); - setCopyState('copied'); - } catch { - setCopyState('error'); - } - }; - const handleBackdropDown = (event: PointerEvent) => { backdropPressed.current = event.target === event.currentTarget; }; @@ -118,8 +106,6 @@ export function CaseStudyDrawer({ children, locale }: { children: ReactNode; loc backdropPressed.current = false; }; - const feedback = copyState === 'copied' ? copy.linkCopied : copyState === 'error' ? copy.copyFailed : ''; - return ( {copy.drawerLabel}
- +
- {feedback}
{children}
diff --git a/src/components/CaseStudyShare.tsx b/src/components/CaseStudyShare.tsx new file mode 100644 index 0000000..76b42d0 --- /dev/null +++ b/src/components/CaseStudyShare.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { + Check, + Copy, + Linkedin, + Mail, + MessageCircle, + Send, + Share2, + X, +} from 'lucide-react'; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent, +} from 'react'; +import { messagesByLocale, type Locale } from '@/i18n/content'; +import styles from './CaseStudyDrawer.module.css'; + +type CopyState = 'idle' | 'copied' | 'error'; + +export function CaseStudyShare({ locale }: { locale: Locale }) { + const copy = messagesByLocale[locale].projects; + const triggerRef = useRef(null); + const dialogRef = useRef(null); + const closeRef = useRef(null); + const skipMotionRef = useRef(false); + const [fallbackOpen, setFallbackOpen] = useState(false); + const [copyState, setCopyState] = useState('idle'); + const [shareData, setShareData] = useState({ title: '', url: '' }); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + if (fallbackOpen && !dialog.open) { + dialog.dataset.motion = skipMotionRef.current ? 'none' : 'standard'; + dialog.showModal(); + requestAnimationFrame(() => { + dialog.dataset.state = 'open'; + closeRef.current?.focus(); + }); + } + + if (!fallbackOpen && dialog.open) dialog.close(); + }, [fallbackOpen]); + + const openFallback = useCallback((skipMotion: boolean) => { + skipMotionRef.current = skipMotion; + setCopyState('idle'); + setShareData({ title: document.title, url: window.location.href }); + setFallbackOpen(true); + }, []); + + const closeFallback = useCallback(() => { + const dialog = dialogRef.current; + if (!dialog?.open) return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduceMotion || dialog.dataset.motion === 'none') { + dialog.close(); + setFallbackOpen(false); + triggerRef.current?.focus({ preventScroll: true }); + return; + } + + dialog.dataset.state = 'closing'; + window.setTimeout(() => { + dialog.close(); + setFallbackOpen(false); + triggerRef.current?.focus({ preventScroll: true }); + }, 140); + }, []); + + const handleShare = async (event: MouseEvent) => { + const data = { title: document.title, url: window.location.href }; + const useNativeShare = window.matchMedia('(max-width: 1100px)').matches + && typeof navigator.share === 'function'; + + if (useNativeShare) { + try { + await navigator.share(data); + return; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + } + } + + openFallback(event.detail === 0); + }; + + const copyCurrentLink = async () => { + try { + await navigator.clipboard.writeText(shareData.url); + setCopyState('copied'); + } catch { + setCopyState('error'); + } + }; + + const encodedUrl = encodeURIComponent(shareData.url); + const encodedTitle = encodeURIComponent(shareData.title); + const encodedMessage = encodeURIComponent(`${shareData.title} ${shareData.url}`); + const feedback = copyState === 'copied' ? copy.linkCopied : copyState === 'error' ? copy.copyFailed : ''; + + return ( + <> + + + { + event.preventDefault(); + closeFallback(); + }} + onClick={(event) => { + if (event.target === event.currentTarget) closeFallback(); + }} + > +
event.stopPropagation()}> +
+
+

{copy.shareDialogTitle}

+

{copy.shareDialogDescription}

+
+ +
+ + + + + {feedback} +
+
+ + ); +} diff --git a/src/i18n/content.ts b/src/i18n/content.ts index b57d46a..431a9c7 100644 --- a/src/i18n/content.ts +++ b/src/i18n/content.ts @@ -55,6 +55,15 @@ export interface SiteMessages { liveProjectLabel: (title: string) => string; drawerLabel: string; closeCaseStudy: string; + shareLabel: string; + shareCaseStudy: string; + shareDialogTitle: string; + shareDialogDescription: string; + closeShareDialog: string; + shareTelegram: string; + shareWhatsApp: string; + shareLinkedIn: string; + shareEmail: string; copyLink: string; linkCopied: string; copyFailed: string; @@ -206,6 +215,15 @@ export const messagesByLocale = { liveProjectLabel: (title) => `Open the live ${title} project in a new tab`, drawerLabel: 'Project case study', closeCaseStudy: 'Close case study', + shareLabel: 'Share', + shareCaseStudy: 'Share this case study', + shareDialogTitle: 'Share case study', + shareDialogDescription: 'Send this project to someone or copy its direct link.', + closeShareDialog: 'Close sharing options', + shareTelegram: 'Telegram', + shareWhatsApp: 'WhatsApp', + shareLinkedIn: 'LinkedIn', + shareEmail: 'Email', copyLink: 'Copy link', linkCopied: 'Link copied', copyFailed: 'Could not copy the link', @@ -358,6 +376,15 @@ export const messagesByLocale = { liveProjectLabel: (title) => `باز کردن نسخه زنده ${title} در زبانه جدید`, drawerLabel: 'مطالعه موردی پروژه', closeCaseStudy: 'بستن مطالعه موردی', + shareLabel: 'اشتراک‌گذاری', + shareCaseStudy: 'اشتراک‌گذاری این مطالعه موردی', + shareDialogTitle: 'اشتراک‌گذاری مطالعه موردی', + shareDialogDescription: 'این پروژه را برای دیگران بفرستید یا پیوند مستقیم آن را کپی کنید.', + closeShareDialog: 'بستن گزینه‌های اشتراک‌گذاری', + shareTelegram: 'تلگرام', + shareWhatsApp: 'واتس‌اپ', + shareLinkedIn: 'لینکدین', + shareEmail: 'ایمیل', copyLink: 'کپی پیوند', linkCopied: 'پیوند کپی شد', copyFailed: 'کپی پیوند ممکن نشد',