diff --git a/AGENTS.md b/AGENTS.md index fb82a4a1..cb515376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,9 +200,12 @@ class PhotoLoader { - Support pluralization with `_one` and `_other` suffixes. - Modify English first, then other languages (ESLint auto-removes unused keys). - **CRITICAL: Avoid nested key conflicts in flat structure.** + - During build, flat dot-separated keys are automatically converted to nested objects. A key cannot be both a string value AND a parent object. - ❌ WRONG: `"action.tag.mode.and": "AND"` + `"action.tag.mode.and.tooltip": "..."` - ✅ CORRECT: `"action.tag.mode.and": "AND"` + `"action.tag.tooltip.and": "..."` - - Rule: A key cannot be both a string value AND a parent object. + - ❌ WRONG: `"photo.share.preview": "Share preview"` + `"photo.share.preview.download": "Download preview"` + - ✅ CORRECT: `"photo.share.preview": "Share preview"` + `"photo.share.downloadPreview": "Download preview"` + - Rule: If a key `a.b.c` exists as a string value, you cannot use `a.b.c.d` as a child key. Use a different parent path like `a.b.d` or rename the child key to avoid the conflict. ### Testing Strategy diff --git a/apps/web/src/modules/social/CopyButton.tsx b/apps/web/src/modules/social/CopyButton.tsx new file mode 100644 index 00000000..3d051fbd --- /dev/null +++ b/apps/web/src/modules/social/CopyButton.tsx @@ -0,0 +1,55 @@ +import { clsxm } from '@afilmory/utils' +import { useCallback, useState } from 'react' + +interface CopyButtonProps { + onCopy: () => Promise + className?: string +} + +export const CopyButton = ({ onCopy, className }: CopyButtonProps) => { + const [isCopying, setIsCopying] = useState(false) + const [isCopied, setIsCopied] = useState(false) + + const handleClick = useCallback(async () => { + try { + setIsCopying(true) + await onCopy() + setIsCopied(true) + // Reset to copy icon after 1 second + setTimeout(() => { + setIsCopied(false) + }, 1000) + } catch { + // Error handling is done in the parent component + } finally { + setIsCopying(false) + } + }, [onCopy]) + + return ( + + ) +} diff --git a/apps/web/src/modules/social/ShareActionButton.tsx b/apps/web/src/modules/social/ShareActionButton.tsx new file mode 100644 index 00000000..b50cd228 --- /dev/null +++ b/apps/web/src/modules/social/ShareActionButton.tsx @@ -0,0 +1,39 @@ +import { clsxm } from '@afilmory/utils' +import type { ReactNode } from 'react' + +interface ShareActionButtonProps { + icon: string + label: string | ReactNode + onClick: () => void | Promise + disabled?: boolean + title?: string + className?: string +} + +export const ShareActionButton = ({ + icon, + label, + onClick, + disabled = false, + title, + className, +}: ShareActionButtonProps) => { + return ( + + ) +} diff --git a/apps/web/src/modules/social/ShareModal.tsx b/apps/web/src/modules/social/ShareModal.tsx new file mode 100644 index 00000000..0e0b0d43 --- /dev/null +++ b/apps/web/src/modules/social/ShareModal.tsx @@ -0,0 +1,300 @@ +import type { ModalComponent } from '@afilmory/ui' +import { Modal } from '@afilmory/ui' +import { clsxm } from '@afilmory/utils' +import type { MouseEvent, ReactElement, ReactNode } from 'react' +import { cloneElement, isValidElement, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { siteConfig } from '~/config' +import type { PhotoManifest } from '~/types/photo' + +import { CopyButton } from './CopyButton' +import { ShareActionButton } from './ShareActionButton' + +// OG image aspect ratio: 1200:628 (from og.renderer.tsx) +const OG_ASPECT_RATIO = 1200 / 628 + +interface ShareModalTriggerProps { + photo: PhotoManifest + trigger: ReactNode + blobSrc?: string +} + +interface ShareSheetProps { + photo: PhotoManifest + blobSrc?: string +} + +interface SocialShareOption { + id: string + label: string + icon: string + url: string +} + +export const ShareModal = ({ photo, trigger, blobSrc }: ShareModalTriggerProps) => { + const handleOpen = useCallback(() => { + Modal.present(ShareSheet, { photo, blobSrc }, { dismissOnOutsideClick: true }) + }, [blobSrc, photo]) + + if (isValidElement(trigger)) { + return cloneElement(trigger as ReactElement, { + // @ts-expect-error - onClick is not a valid prop for the trigger element + onClick: (event: MouseEvent) => { + // @ts-expect-error - trigger is a valid React element + trigger.props?.onClick?.(event) + if (event.defaultPrevented) return + handleOpen() + }, + }) + } + + return ( + + ) +} + +const ShareSheet: ModalComponent = ({ photo, blobSrc, dismiss }) => { + const { t } = useTranslation() + const [isDownloadingOriginal, setIsDownloadingOriginal] = useState(false) + const [isDownloadingPreview, setIsDownloadingPreview] = useState(false) + const [isOgImageLoading, setIsOgImageLoading] = useState(true) + + const resolvedBaseUrl = useMemo(() => { + if (typeof window !== 'undefined' && window.location?.origin) { + return window.location.origin + } + return siteConfig.url?.replace(/\/$/, '') ?? '' + }, []) + + const shareLink = useMemo(() => { + const pathname = `/photos/${photo.id}` + if (!resolvedBaseUrl) return pathname + return `${resolvedBaseUrl}${pathname}` + }, [photo.id, resolvedBaseUrl]) + + const ogPreviewUrl = useMemo(() => { + const path = `/og/${photo.id}` + if (!resolvedBaseUrl) return path + return `${resolvedBaseUrl}${path}` + }, [photo.id, resolvedBaseUrl]) + + const shareTitle = photo.title || t('photo.share.default.title') + const shareText = t('photo.share.text', { title: shareTitle }) + + const canUseNativeShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function' + + const socialOptions = useMemo(() => getSocialOptions(t), [t]) + + const handleNativeShare = useCallback(async () => { + if (!canUseNativeShare) return + + try { + const files = await buildShareFiles(photo, blobSrc) + await navigator.share({ + title: shareTitle, + text: shareText, + url: shareLink, + ...(files.length > 0 ? { files } : {}), + }) + dismiss() + } catch { + await navigator.clipboard.writeText(shareLink) + toast.success(t('photo.share.linkCopied')) + dismiss() + } + }, [blobSrc, canUseNativeShare, dismiss, photo, shareLink, shareText, shareTitle, t]) + + const handleCopyLink = useCallback(async () => { + try { + await navigator.clipboard.writeText(shareLink) + toast.success(t('photo.share.linkCopied')) + } catch { + toast.error(t('photo.share.copy.failed')) + throw new Error('Failed to copy') + } + }, [shareLink, t]) + + const handleDownloadOriginal = useCallback(async () => { + try { + setIsDownloadingOriginal(true) + await downloadFile(photo.originalUrl, `${photo.id}.jpg`) + toast.success(t('photo.share.download.original')) + } catch { + toast.error(t('photo.share.copy.failed')) + } finally { + setIsDownloadingOriginal(false) + } + }, [photo.id, photo.originalUrl, t]) + + const handleDownloadPreview = useCallback(async () => { + try { + setIsDownloadingPreview(true) + await downloadFile(ogPreviewUrl, `${photo.id}-og.png`) + toast.success(t('photo.share.downloadPreview')) + } catch { + toast.error(t('photo.share.copy.failed')) + } finally { + setIsDownloadingPreview(false) + } + }, [ogPreviewUrl, photo.id, t]) + + const handleSocialShare = useCallback( + (urlTemplate: string) => { + const encodedUrl = encodeURIComponent(shareLink) + const encodedTitle = encodeURIComponent(shareTitle) + const encodedText = encodeURIComponent(shareText) + const finalUrl = urlTemplate + .replace('{url}', encodedUrl) + .replace('{title}', encodedTitle) + .replace('{text}', encodedText) + window.open(finalUrl, '_blank', 'width=600,height=600') + dismiss() + }, + [dismiss, shareLink, shareText, shareTitle], + ) + + return ( +
+
+
+

{t('photo.share.title')}

+
{shareTitle}
+ {photo.location?.city &&

{photo.location.city}

} +
+
+ +
+
+

{t('photo.share.link')}

+
+
+ {shareLink} + + +
+
+ +
+

{t('photo.share.preview')}

+
+ {/* Fixed aspect ratio placeholder to prevent CLS */} +
+ {isOgImageLoading && ( +
+
+
+ )} + {photo.title} setIsOgImageLoading(false)} + onError={() => setIsOgImageLoading(false)} + /> +
+
+
+ +
+

{t('photo.share.actions')}

+
+ {/* Native share button (if available) */} + {canUseNativeShare && ( + + )} + {/* Social share buttons */} + {socialOptions.map((option) => ( + handleSocialShare(option.url)} + /> + ))} + {/* Download buttons */} + + +
+
+
+ ) +} + +ShareSheet.contentClassName = 'max-w-3xl w-full z-1000000' + +async function downloadFile(url: string, filename: string) { + const response = await fetch(url) + if (!response.ok) { + throw new Error('Unable to download file') + } + const blob = await response.blob() + const blobUrl = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = blobUrl + link.download = filename + document.body.append(link) + link.click() + link.remove() + URL.revokeObjectURL(blobUrl) +} + +async function buildShareFiles(photo: PhotoManifest, blobSrc?: string) { + const imageUrl = blobSrc || photo.originalUrl + try { + const response = await fetch(imageUrl) + const blob = await response.blob() + return [new File([blob], `${photo.title || photo.id}.jpg`, { type: blob.type || 'image/jpeg' })] + } catch { + return [] + } +} + +function getSocialOptions(t: ReturnType['t']): SocialShareOption[] { + return [ + { + id: 'twitter', + label: 'Twitter', + icon: 'i-mingcute-twitter-fill', + url: 'https://twitter.com/intent/tweet?text={text}&url={url}', + }, + { + id: 'telegram', + label: 'Telegram', + icon: 'i-mingcute-telegram-line', + url: 'https://t.me/share/url?url={url}&text={text}', + }, + { + id: 'weibo', + label: t('photo.share.weibo'), + icon: 'i-mingcute-weibo-line', + url: 'https://service.weibo.com/share/share.php?url={url}&title={text}', + }, + ] +} + +ShareSheet.displayName = 'ShareSheet' diff --git a/apps/web/src/modules/social/SharePanel.tsx b/apps/web/src/modules/social/SharePanel.tsx deleted file mode 100644 index 0f7765ff..00000000 --- a/apps/web/src/modules/social/SharePanel.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { RootPortal } from '@afilmory/ui' -import { clsxm, Spring } from '@afilmory/utils' -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' -import { AnimatePresence, m } from 'motion/react' -import { useCallback, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' - -import { injectConfig, siteConfig } from '~/config' -import type { PhotoManifest } from '~/types/photo' - -interface SharePanelProps { - photo: PhotoManifest - trigger: React.ReactNode - blobSrc?: string -} - -interface ShareOption { - id: string - label: string - icon: string - action: () => Promise | void - color?: string - bgColor?: string -} - -interface SocialShareOption { - id: string - label: string - icon: string - url: string - color: string - bgColor: string -} - -export const SharePanel = ({ photo, trigger, blobSrc }: SharePanelProps) => { - const { t } = useTranslation() - const [isOpen, setIsOpen] = useState(false) - - // 社交媒体分享选项 - const socialOptions: SocialShareOption[] = [ - { - id: 'twitter', - label: 'Twitter', - icon: 'i-mingcute-twitter-fill', - url: 'https://twitter.com/intent/tweet?text={text}&url={url}', - color: 'text-white', - bgColor: 'bg-sky-500', - }, - { - id: 'facebook', - label: 'Facebook', - icon: 'i-mingcute-facebook-line', - url: 'https://www.facebook.com/sharer/sharer.php?u={url}', - color: 'text-white', - bgColor: 'bg-[#1877F2]', - }, - { - id: 'telegram', - label: 'Telegram', - icon: 'i-mingcute-telegram-line', - url: 'https://t.me/share/url?url={url}&text={text}', - color: 'text-white', - bgColor: 'bg-[#0088CC]', - }, - { - id: 'weibo', - label: t('photo.share.weibo'), - icon: 'i-mingcute-weibo-line', - url: 'https://service.weibo.com/share/share.php?url={url}&title={text}', - color: 'text-white', - bgColor: 'bg-[#E6162D]', - }, - ] - - const handleNativeShare = useCallback(async () => { - const shareUrl = window.location.href - const shareTitle = photo.title || t('photo.share.default.title') - const shareText = t('photo.share.text', { title: shareTitle }) - - try { - // 优先使用 blobSrc(转换后的图片),如果没有则使用 originalUrl - const imageUrl = blobSrc || photo.originalUrl - const response = await fetch(imageUrl) - const blob = await response.blob() - const file = new File([blob], `${photo.title || 'photo'}.jpg`, { - type: blob.type || 'image/jpeg', - }) - - // 检查是否支持文件分享 - if (navigator.canShare && navigator.canShare({ files: [file] })) { - await navigator.share({ - title: shareTitle, - text: shareText, - url: shareUrl, - files: [file], - }) - } else { - // 不支持文件分享,只分享链接 - await navigator.share({ - title: shareTitle, - text: shareText, - url: shareUrl, - }) - } - setIsOpen(false) - } catch { - // 如果分享失败,复制链接 - await navigator.clipboard.writeText(shareUrl) - toast.success(t('photo.share.link.copied')) - setIsOpen(false) - } - }, [photo.title, blobSrc, photo.originalUrl, t]) - - const handleCopyLink = useCallback(async () => { - try { - await navigator.clipboard.writeText(window.location.href) - toast.success(t('photo.share.link.copied')) - setIsOpen(false) - } catch { - toast.error(t('photo.share.copy.failed')) - } - }, [t]) - const shareCodeRef = useRef(null) - - const handleCopyEmbedCode = useCallback(async () => { - try { - const embedCode = shareCodeRef.current?.textContent - if (embedCode) { - await navigator.clipboard.writeText(embedCode) - } - toast.success(t('photo.share.embed.copied')) - setIsOpen(false) - } catch { - toast.error(t('photo.share.copy.failed')) - } - }, [t]) - - const handleSocialShare = useCallback( - (url: string) => { - const shareUrl = encodeURIComponent(window.location.href) - const defaultTitle = t('photo.share.default.title') - const shareTitle = encodeURIComponent(photo.title || defaultTitle) - const shareText = encodeURIComponent(t('photo.share.text', { title: photo.title || defaultTitle })) - - const finalUrl = url.replace('{url}', shareUrl).replace('{title}', shareTitle).replace('{text}', shareText) - - window.open(finalUrl, '_blank', 'width=600,height=400') - setIsOpen(false) - }, - [photo.title, t], - ) - - // 功能选项 - const actionOptions: ShareOption[] = [ - ...(typeof navigator !== 'undefined' && 'share' in navigator - ? [ - { - id: 'native-share', - label: t('photo.share.system'), - icon: 'i-mingcute-share-2-line', - action: handleNativeShare, - color: 'text-blue-500', - }, - ] - : []), - { - id: 'copy-link', - label: t('photo.share.copy.link'), - icon: 'i-mingcute-link-line', - action: handleCopyLink, - }, - { - id: 'copy-embed', - label: t('photo.share.embed.code'), - icon: 'i-mingcute-code-line', - action: handleCopyEmbedCode, - color: 'text-purple-500', - }, - ] - - return ( - - {trigger} - - - {isOpen && ( - - - - {/* Inner glow layer */} -
- {/* 标题区域 */} -
-

{t('photo.share.title')}

- {photo.title &&

{photo.title}

} -
- - {/* 社交媒体分享 - 第一排 */} -
-
-

- {t('photo.share.social.media')} -

-
-
- {socialOptions.map((option) => ( - - ))} -
-
- - {/* 嵌入代码 - 第二排 */} - {injectConfig.useNext && ( -
-
-

- {t('photo.share.embed.code')} -

-

{t('photo.share.embed.description')}

-
-
-
- { - if (ref) { - shareCodeRef.current = ref - } - return () => { - shareCodeRef.current = null - } - }} - className="text-text-secondary font-mono text-xs break-all whitespace-pre select-all" - > - {`