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:
Innei
2025-11-30 22:50:33 +08:00
parent d9e09b375b
commit b6b7941ea2
14 changed files with 433 additions and 339 deletions

View File

@@ -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

View 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>
)
}

View 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>
)
}

View 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'

View File

@@ -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>
)
}

View File

@@ -1,3 +1,3 @@
export * from './comments'
export * from './Reaction'
export * from './SharePanel'
export * from './ShareModal'

View File

@@ -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={

View File

@@ -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>

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",