mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 14:44:48 +00:00
feat: implement ShareModal and related components for enhanced photo sharing
- Introduced `ShareModal` to replace the deprecated `SharePanel`, providing a more streamlined sharing experience. - Added `CopyButton` and `ShareActionButton` components for improved user interaction when sharing photos. - Updated `PhotoViewer` to utilize the new `ShareModal` for sharing functionality. - Removed `SharePanel` and updated localization files to include new sharing strings in multiple languages. - Enhanced the handling of share links and download options within the modal. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
55
apps/web/src/modules/social/CopyButton.tsx
Normal file
55
apps/web/src/modules/social/CopyButton.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
interface CopyButtonProps {
|
||||
onCopy: () => Promise<void>
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={clsxm(
|
||||
'shrink-0 rounded-lg border border-white/10 p-1.5 text-white/80 transition-all duration-300 hover:text-white disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
disabled={isCopying || isCopied}
|
||||
>
|
||||
<div className="relative size-4">
|
||||
<i
|
||||
className={clsxm(
|
||||
'i-mingcute-copy-2-line absolute inset-0 text-sm transition-all duration-300',
|
||||
isCopied ? 'scale-0 opacity-0' : 'scale-100 opacity-100',
|
||||
)}
|
||||
/>
|
||||
<i
|
||||
className={clsxm(
|
||||
'i-mingcute-check-line absolute inset-0 text-sm transition-all duration-300',
|
||||
isCopied ? 'scale-100 opacity-100' : 'scale-0 opacity-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
39
apps/web/src/modules/social/ShareActionButton.tsx
Normal file
39
apps/web/src/modules/social/ShareActionButton.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ShareActionButtonProps {
|
||||
icon: string
|
||||
label: string | ReactNode
|
||||
onClick: () => void | Promise<void>
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ShareActionButton = ({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
title,
|
||||
className,
|
||||
}: ShareActionButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={clsxm(
|
||||
'glassmorphic-btn flex flex-col items-center gap-1.5 rounded border-white/10 bg-white/5 px-2 py-2.5 text-xs text-white/80',
|
||||
'transition-all duration-200',
|
||||
'hover:border-white/20 hover:bg-white/8 hover:text-white',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-white/10 disabled:hover:bg-white/5',
|
||||
className,
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
<i className={clsxm(icon, 'text-lg text-white')} />
|
||||
<span className="w-full truncate text-center text-[10px] leading-tight text-white!">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
300
apps/web/src/modules/social/ShareModal.tsx
Normal file
300
apps/web/src/modules/social/ShareModal.tsx
Normal file
@@ -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<HTMLElement>) => {
|
||||
// @ts-expect-error - trigger is a valid React element
|
||||
trigger.props?.onClick?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
handleOpen()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleOpen} className="contents">
|
||||
{trigger}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const ShareSheet: ModalComponent<ShareSheetProps> = ({ 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 (
|
||||
<div className="w-full text-base">
|
||||
<div className="mb-4">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-0.5 text-xs font-medium text-white/50">{t('photo.share.title')}</p>
|
||||
<div className="truncate text-lg font-semibold text-white">{shareTitle}</div>
|
||||
{photo.location?.city && <p className="mt-0.5 text-xs text-white/40">{photo.location.city}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-white/50">{t('photo.share.link')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-white">
|
||||
<span className="flex-1 truncate text-xs">{shareLink}</span>
|
||||
|
||||
<CopyButton onCopy={handleCopyLink} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
<p className="text-xs font-medium text-white/50">{t('photo.share.preview')}</p>
|
||||
<div className="relative overflow-hidden rounded-xl border border-white/10 bg-black/40">
|
||||
{/* Fixed aspect ratio placeholder to prevent CLS */}
|
||||
<div className="w-full" style={{ aspectRatio: OG_ASPECT_RATIO }}>
|
||||
{isOgImageLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/5">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={ogPreviewUrl}
|
||||
alt={photo.title}
|
||||
className={clsxm(
|
||||
'h-full w-full object-cover transition-opacity duration-300',
|
||||
isOgImageLoading ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
loading="lazy"
|
||||
onLoad={() => setIsOgImageLoading(false)}
|
||||
onError={() => setIsOgImageLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-white/50">{t('photo.share.actions')}</p>
|
||||
<div className={clsxm('grid gap-2', canUseNativeShare ? 'grid-cols-6' : 'grid-cols-5')}>
|
||||
{/* Native share button (if available) */}
|
||||
{canUseNativeShare && (
|
||||
<ShareActionButton
|
||||
icon="i-mingcute-share-2-line"
|
||||
label="System"
|
||||
onClick={handleNativeShare}
|
||||
title={t('photo.share.system')}
|
||||
/>
|
||||
)}
|
||||
{/* Social share buttons */}
|
||||
{socialOptions.map((option) => (
|
||||
<ShareActionButton
|
||||
key={option.id}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onClick={() => handleSocialShare(option.url)}
|
||||
/>
|
||||
))}
|
||||
{/* Download buttons */}
|
||||
<ShareActionButton
|
||||
icon="i-mingcute-download-3-line"
|
||||
label={isDownloadingOriginal ? '…' : 'Original'}
|
||||
onClick={handleDownloadOriginal}
|
||||
disabled={isDownloadingOriginal}
|
||||
title={t('photo.share.download.original')}
|
||||
/>
|
||||
<ShareActionButton
|
||||
icon="i-lucide-image"
|
||||
label={isDownloadingPreview ? '…' : 'Preview'}
|
||||
onClick={handleDownloadPreview}
|
||||
disabled={isDownloadingPreview}
|
||||
title={t('photo.share.downloadPreview')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<typeof useTranslation>['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'
|
||||
@@ -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> | 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<HTMLElement>(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 (
|
||||
<DropdownMenuPrimitive.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuPrimitive.Trigger asChild>{trigger}</DropdownMenuPrimitive.Trigger>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<RootPortal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="z-10000 min-w-[280px] will-change-[opacity,transform]"
|
||||
asChild
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="border-accent/20 rounded-2xl border p-4 backdrop-blur-2xl"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
|
||||
boxShadow:
|
||||
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{/* Inner glow layer */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
}}
|
||||
/>
|
||||
{/* 标题区域 */}
|
||||
<div className="relative mb-4 text-center">
|
||||
<h3 className="text-text font-semibold">{t('photo.share.title')}</h3>
|
||||
{photo.title && <p className="text-text-secondary mt-1 line-clamp-1 text-sm">{photo.title}</p>}
|
||||
</div>
|
||||
|
||||
{/* 社交媒体分享 - 第一排 */}
|
||||
<div className="relative mb-6">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-text-secondary text-xs font-medium tracking-wide uppercase">
|
||||
{t('photo.share.social.media')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex gap-6 px-2">
|
||||
{socialOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="group flex flex-col items-center gap-2"
|
||||
onClick={() => handleSocialShare(option.url)}
|
||||
>
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex size-12 items-center justify-center rounded-full transition-all duration-200',
|
||||
option.bgColor,
|
||||
'group-hover:scale-110 group-active:scale-95',
|
||||
'shadow-lg',
|
||||
)}
|
||||
>
|
||||
<i className={clsxm(option.icon, 'size-5', option.color)} />
|
||||
</div>
|
||||
<span className="text-text-secondary text-xs font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 嵌入代码 - 第二排 */}
|
||||
{injectConfig.useNext && (
|
||||
<div className="relative mb-6">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-text-secondary text-xs font-medium tracking-wide uppercase">
|
||||
{t('photo.share.embed.code')}
|
||||
</h4>
|
||||
<p className="text-text-tertiary mt-1 text-xs">{t('photo.share.embed.description')}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="border-accent/20 bg-accent/5 rounded-lg border p-3">
|
||||
<code
|
||||
ref={(ref) => {
|
||||
if (ref) {
|
||||
shareCodeRef.current = ref
|
||||
}
|
||||
return () => {
|
||||
shareCodeRef.current = null
|
||||
}
|
||||
}}
|
||||
className="text-text-secondary font-mono text-xs break-all whitespace-pre select-all"
|
||||
>
|
||||
{`<iframe
|
||||
src="${siteConfig.url.replace(/\/$/, '')}/share/iframe?id=${photo.id}"
|
||||
style="width: 100%; aspect-ratio: ${photo.width} / ${photo.height}"
|
||||
allowTransparency
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
/>`}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="glassmorphic-btn border-accent/20 bg-accent/5 absolute top-2 right-2 flex size-7 items-center justify-center rounded-md border backdrop-blur-3xl transition-all duration-200"
|
||||
onClick={handleCopyEmbedCode}
|
||||
>
|
||||
<i className="i-mingcute-copy-line size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 功能选项 - 第三排 */}
|
||||
<div className="relative">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-text-secondary text-xs font-medium tracking-wide uppercase">
|
||||
{t('photo.share.actions')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{actionOptions
|
||||
.filter((option) => option.id !== 'copy-embed')
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className="glassmorphic-btn group relative flex cursor-pointer items-center rounded-lg px-2 py-2 text-sm transition-all duration-200 outline-none select-none"
|
||||
onClick={() => option.action()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-accent/10 flex size-7 items-center justify-center rounded-full transition-colors duration-200">
|
||||
<i className={clsxm(option.icon, 'size-3.5', option.color || 'text-text-secondary')} />
|
||||
</div>
|
||||
<span className="text-text text-xs font-medium">{option.label}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</RootPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from './comments'
|
||||
export * from './Reaction'
|
||||
export * from './SharePanel'
|
||||
export * from './ShareModal'
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useMobile } from '~/hooks/useMobile'
|
||||
import type { LoadingIndicatorRef } from '~/modules/inspector/LoadingIndicator'
|
||||
import { LoadingIndicator } from '~/modules/inspector/LoadingIndicator'
|
||||
import { PhotoInspector } from '~/modules/inspector/PhotoInspector'
|
||||
import { SharePanel } from '~/modules/social/SharePanel'
|
||||
import { ShareModal } from '~/modules/social/ShareModal'
|
||||
import type { PhotoManifest } from '~/types/photo'
|
||||
|
||||
import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview'
|
||||
@@ -235,7 +235,7 @@ export const PhotoViewer = ({
|
||||
{/* 右侧按钮组 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 分享按钮 */}
|
||||
<SharePanel
|
||||
<ShareModal
|
||||
photo={currentPhoto}
|
||||
blobSrc={currentBlobSrc || undefined}
|
||||
trigger={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Toaster } from '@afilmory/ui'
|
||||
import { ModalContainer, Toaster } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { Provider } from 'jotai'
|
||||
import { domMax, LazyMotion, MotionConfig } from 'motion/react'
|
||||
@@ -28,6 +28,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => {
|
||||
|
||||
<ContextMenuProvider />
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
<ModalContainer />
|
||||
</QueryProvider>
|
||||
</Provider>
|
||||
</MotionConfig>
|
||||
|
||||
@@ -379,10 +379,14 @@
|
||||
"photo.share.copy.failed": "Copy failed",
|
||||
"photo.share.copy.link": "Copy Link",
|
||||
"photo.share.default.title": "Photo Share",
|
||||
"photo.share.download.original": "Download original",
|
||||
"photo.share.downloadPreview": "Download preview",
|
||||
"photo.share.embed.code": "Embed Code",
|
||||
"photo.share.embed.copied": "Embed code copied to clipboard",
|
||||
"photo.share.embed.description": "Copy this code to embed the photo on your website",
|
||||
"photo.share.link.copied": "Link copied to clipboard",
|
||||
"photo.share.link": "Share link",
|
||||
"photo.share.linkCopied": "Link copied to clipboard",
|
||||
"photo.share.preview": "Share preview",
|
||||
"photo.share.social.media": "Social Media",
|
||||
"photo.share.system": "System Share",
|
||||
"photo.share.text": "Check out this beautiful photo: {{title}}",
|
||||
|
||||
@@ -347,10 +347,14 @@
|
||||
"photo.share.copy.failed": "コピーに失敗しました",
|
||||
"photo.share.copy.link": "リンクをコピー",
|
||||
"photo.share.default.title": "写真の共有",
|
||||
"photo.share.download.original": "オリジナルをダウンロード",
|
||||
"photo.share.downloadPreview": "プレビューをダウンロード",
|
||||
"photo.share.embed.code": "埋め込みコード",
|
||||
"photo.share.embed.copied": "埋め込みコードがクリップボードにコピーされました",
|
||||
"photo.share.embed.description": "このコードをコピーして、あなたのウェブサイトに写真を埋め込んでください",
|
||||
"photo.share.link.copied": "リンクがクリップボードにコピーされました",
|
||||
"photo.share.link": "共有リンク",
|
||||
"photo.share.linkCopied": "リンクがクリップボードにコピーされました",
|
||||
"photo.share.preview": "共有プレビュー",
|
||||
"photo.share.social.media": "ソーシャルメディア",
|
||||
"photo.share.system": "システム共有",
|
||||
"photo.share.text": "この素敵な写真を見てください:{{title}}",
|
||||
|
||||
@@ -347,10 +347,14 @@
|
||||
"photo.share.copy.failed": "복사 실패",
|
||||
"photo.share.copy.link": "링크 복사",
|
||||
"photo.share.default.title": "사진 공유",
|
||||
"photo.share.download.original": "원본 다운로드",
|
||||
"photo.share.downloadPreview": "미리보기 다운로드",
|
||||
"photo.share.embed.code": "임베드 코드",
|
||||
"photo.share.embed.copied": "임베드 코드를 클립보드에 복사했습니다",
|
||||
"photo.share.embed.description": "이 코드를 복사하여 웹사이트에 사진을 임베드하세요",
|
||||
"photo.share.link.copied": "링크를 클립보드에 복사했습니다",
|
||||
"photo.share.link": "공유 링크",
|
||||
"photo.share.linkCopied": "링크를 클립보드에 복사했습니다",
|
||||
"photo.share.preview": "공유 미리보기",
|
||||
"photo.share.social.media": "소셜 미디어",
|
||||
"photo.share.system": "시스템 공유",
|
||||
"photo.share.text": "이 멋진 사진을 확인해 보세요: {{title}}",
|
||||
|
||||
@@ -376,10 +376,14 @@
|
||||
"photo.share.copy.failed": "复制失败",
|
||||
"photo.share.copy.link": "复制链接",
|
||||
"photo.share.default.title": "照片分享",
|
||||
"photo.share.download.original": "下载原图",
|
||||
"photo.share.downloadPreview": "下载预览图",
|
||||
"photo.share.embed.code": "嵌入代码",
|
||||
"photo.share.embed.copied": "嵌入代码已复制到剪贴板",
|
||||
"photo.share.embed.description": "复制此代码以在您的网站中嵌入照片",
|
||||
"photo.share.link.copied": "链接已复制到剪贴板",
|
||||
"photo.share.link": "分享链接",
|
||||
"photo.share.linkCopied": "链接已复制到剪贴板",
|
||||
"photo.share.preview": "分享预览图",
|
||||
"photo.share.social.media": "社交媒体",
|
||||
"photo.share.system": "系统分享",
|
||||
"photo.share.text": "看看这张漂亮的照片:{{title}}",
|
||||
|
||||
@@ -347,10 +347,14 @@
|
||||
"photo.share.copy.failed": "複製失敗",
|
||||
"photo.share.copy.link": "複製連結",
|
||||
"photo.share.default.title": "照片分享",
|
||||
"photo.share.download.original": "下載原圖",
|
||||
"photo.share.downloadPreview": "下載預覽圖",
|
||||
"photo.share.embed.code": "嵌入代碼",
|
||||
"photo.share.embed.copied": "嵌入代碼已複製到剪貼簿",
|
||||
"photo.share.embed.description": "複製此代碼以在您的網站中嵌入照片",
|
||||
"photo.share.link.copied": "連結已複製到剪貼簿",
|
||||
"photo.share.link": "分享連結",
|
||||
"photo.share.linkCopied": "連結已複製到剪貼簿",
|
||||
"photo.share.preview": "分享預覽圖",
|
||||
"photo.share.social.media": "社交媒體",
|
||||
"photo.share.system": "系統分享",
|
||||
"photo.share.text": "看看這張漂亮的照片:{{title}}",
|
||||
|
||||
@@ -346,10 +346,14 @@
|
||||
"photo.share.copy.failed": "複製失敗",
|
||||
"photo.share.copy.link": "複製連結",
|
||||
"photo.share.default.title": "照片分享",
|
||||
"photo.share.download.original": "下載原圖",
|
||||
"photo.share.downloadPreview": "下載預覽圖",
|
||||
"photo.share.embed.code": "嵌入代碼",
|
||||
"photo.share.embed.copied": "嵌入代碼已複製到剪貼簿",
|
||||
"photo.share.embed.description": "複製此代碼以在您的網站中嵌入照片",
|
||||
"photo.share.link.copied": "連結已複製到剪貼簿",
|
||||
"photo.share.link": "分享連結",
|
||||
"photo.share.linkCopied": "連結已複製到剪貼簿",
|
||||
"photo.share.preview": "分享預覽圖",
|
||||
"photo.share.social.media": "社群媒體",
|
||||
"photo.share.system": "系統分享",
|
||||
"photo.share.text": "看看這張漂亮的照片:{{title}}",
|
||||
|
||||
Reference in New Issue
Block a user