mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +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.
|
- Support pluralization with `_one` and `_other` suffixes.
|
||||||
- Modify English first, then other languages (ESLint auto-removes unused keys).
|
- Modify English first, then other languages (ESLint auto-removes unused keys).
|
||||||
- **CRITICAL: Avoid nested key conflicts in flat structure.**
|
- **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": "..."`
|
- ❌ WRONG: `"action.tag.mode.and": "AND"` + `"action.tag.mode.and.tooltip": "..."`
|
||||||
- ✅ CORRECT: `"action.tag.mode.and": "AND"` + `"action.tag.tooltip.and": "..."`
|
- ✅ 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
|
### 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 './comments'
|
||||||
export * from './Reaction'
|
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 type { LoadingIndicatorRef } from '~/modules/inspector/LoadingIndicator'
|
||||||
import { LoadingIndicator } from '~/modules/inspector/LoadingIndicator'
|
import { LoadingIndicator } from '~/modules/inspector/LoadingIndicator'
|
||||||
import { PhotoInspector } from '~/modules/inspector/PhotoInspector'
|
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 type { PhotoManifest } from '~/types/photo'
|
||||||
|
|
||||||
import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview'
|
import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview'
|
||||||
@@ -235,7 +235,7 @@ export const PhotoViewer = ({
|
|||||||
{/* 右侧按钮组 */}
|
{/* 右侧按钮组 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 分享按钮 */}
|
{/* 分享按钮 */}
|
||||||
<SharePanel
|
<ShareModal
|
||||||
photo={currentPhoto}
|
photo={currentPhoto}
|
||||||
blobSrc={currentBlobSrc || undefined}
|
blobSrc={currentBlobSrc || undefined}
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Toaster } from '@afilmory/ui'
|
import { ModalContainer, Toaster } from '@afilmory/ui'
|
||||||
import { Spring } from '@afilmory/utils'
|
import { Spring } from '@afilmory/utils'
|
||||||
import { Provider } from 'jotai'
|
import { Provider } from 'jotai'
|
||||||
import { domMax, LazyMotion, MotionConfig } from 'motion/react'
|
import { domMax, LazyMotion, MotionConfig } from 'motion/react'
|
||||||
@@ -28,6 +28,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => {
|
|||||||
|
|
||||||
<ContextMenuProvider />
|
<ContextMenuProvider />
|
||||||
<I18nProvider>{children}</I18nProvider>
|
<I18nProvider>{children}</I18nProvider>
|
||||||
|
<ModalContainer />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</Provider>
|
</Provider>
|
||||||
</MotionConfig>
|
</MotionConfig>
|
||||||
|
|||||||
@@ -379,10 +379,14 @@
|
|||||||
"photo.share.copy.failed": "Copy failed",
|
"photo.share.copy.failed": "Copy failed",
|
||||||
"photo.share.copy.link": "Copy Link",
|
"photo.share.copy.link": "Copy Link",
|
||||||
"photo.share.default.title": "Photo Share",
|
"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.code": "Embed Code",
|
||||||
"photo.share.embed.copied": "Embed code copied to clipboard",
|
"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.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.social.media": "Social Media",
|
||||||
"photo.share.system": "System Share",
|
"photo.share.system": "System Share",
|
||||||
"photo.share.text": "Check out this beautiful photo: {{title}}",
|
"photo.share.text": "Check out this beautiful photo: {{title}}",
|
||||||
|
|||||||
@@ -347,10 +347,14 @@
|
|||||||
"photo.share.copy.failed": "コピーに失敗しました",
|
"photo.share.copy.failed": "コピーに失敗しました",
|
||||||
"photo.share.copy.link": "リンクをコピー",
|
"photo.share.copy.link": "リンクをコピー",
|
||||||
"photo.share.default.title": "写真の共有",
|
"photo.share.default.title": "写真の共有",
|
||||||
|
"photo.share.download.original": "オリジナルをダウンロード",
|
||||||
|
"photo.share.downloadPreview": "プレビューをダウンロード",
|
||||||
"photo.share.embed.code": "埋め込みコード",
|
"photo.share.embed.code": "埋め込みコード",
|
||||||
"photo.share.embed.copied": "埋め込みコードがクリップボードにコピーされました",
|
"photo.share.embed.copied": "埋め込みコードがクリップボードにコピーされました",
|
||||||
"photo.share.embed.description": "このコードをコピーして、あなたのウェブサイトに写真を埋め込んでください",
|
"photo.share.embed.description": "このコードをコピーして、あなたのウェブサイトに写真を埋め込んでください",
|
||||||
"photo.share.link.copied": "リンクがクリップボードにコピーされました",
|
"photo.share.link": "共有リンク",
|
||||||
|
"photo.share.linkCopied": "リンクがクリップボードにコピーされました",
|
||||||
|
"photo.share.preview": "共有プレビュー",
|
||||||
"photo.share.social.media": "ソーシャルメディア",
|
"photo.share.social.media": "ソーシャルメディア",
|
||||||
"photo.share.system": "システム共有",
|
"photo.share.system": "システム共有",
|
||||||
"photo.share.text": "この素敵な写真を見てください:{{title}}",
|
"photo.share.text": "この素敵な写真を見てください:{{title}}",
|
||||||
|
|||||||
@@ -347,10 +347,14 @@
|
|||||||
"photo.share.copy.failed": "복사 실패",
|
"photo.share.copy.failed": "복사 실패",
|
||||||
"photo.share.copy.link": "링크 복사",
|
"photo.share.copy.link": "링크 복사",
|
||||||
"photo.share.default.title": "사진 공유",
|
"photo.share.default.title": "사진 공유",
|
||||||
|
"photo.share.download.original": "원본 다운로드",
|
||||||
|
"photo.share.downloadPreview": "미리보기 다운로드",
|
||||||
"photo.share.embed.code": "임베드 코드",
|
"photo.share.embed.code": "임베드 코드",
|
||||||
"photo.share.embed.copied": "임베드 코드를 클립보드에 복사했습니다",
|
"photo.share.embed.copied": "임베드 코드를 클립보드에 복사했습니다",
|
||||||
"photo.share.embed.description": "이 코드를 복사하여 웹사이트에 사진을 임베드하세요",
|
"photo.share.embed.description": "이 코드를 복사하여 웹사이트에 사진을 임베드하세요",
|
||||||
"photo.share.link.copied": "링크를 클립보드에 복사했습니다",
|
"photo.share.link": "공유 링크",
|
||||||
|
"photo.share.linkCopied": "링크를 클립보드에 복사했습니다",
|
||||||
|
"photo.share.preview": "공유 미리보기",
|
||||||
"photo.share.social.media": "소셜 미디어",
|
"photo.share.social.media": "소셜 미디어",
|
||||||
"photo.share.system": "시스템 공유",
|
"photo.share.system": "시스템 공유",
|
||||||
"photo.share.text": "이 멋진 사진을 확인해 보세요: {{title}}",
|
"photo.share.text": "이 멋진 사진을 확인해 보세요: {{title}}",
|
||||||
|
|||||||
@@ -376,10 +376,14 @@
|
|||||||
"photo.share.copy.failed": "复制失败",
|
"photo.share.copy.failed": "复制失败",
|
||||||
"photo.share.copy.link": "复制链接",
|
"photo.share.copy.link": "复制链接",
|
||||||
"photo.share.default.title": "照片分享",
|
"photo.share.default.title": "照片分享",
|
||||||
|
"photo.share.download.original": "下载原图",
|
||||||
|
"photo.share.downloadPreview": "下载预览图",
|
||||||
"photo.share.embed.code": "嵌入代码",
|
"photo.share.embed.code": "嵌入代码",
|
||||||
"photo.share.embed.copied": "嵌入代码已复制到剪贴板",
|
"photo.share.embed.copied": "嵌入代码已复制到剪贴板",
|
||||||
"photo.share.embed.description": "复制此代码以在您的网站中嵌入照片",
|
"photo.share.embed.description": "复制此代码以在您的网站中嵌入照片",
|
||||||
"photo.share.link.copied": "链接已复制到剪贴板",
|
"photo.share.link": "分享链接",
|
||||||
|
"photo.share.linkCopied": "链接已复制到剪贴板",
|
||||||
|
"photo.share.preview": "分享预览图",
|
||||||
"photo.share.social.media": "社交媒体",
|
"photo.share.social.media": "社交媒体",
|
||||||
"photo.share.system": "系统分享",
|
"photo.share.system": "系统分享",
|
||||||
"photo.share.text": "看看这张漂亮的照片:{{title}}",
|
"photo.share.text": "看看这张漂亮的照片:{{title}}",
|
||||||
|
|||||||
@@ -347,10 +347,14 @@
|
|||||||
"photo.share.copy.failed": "複製失敗",
|
"photo.share.copy.failed": "複製失敗",
|
||||||
"photo.share.copy.link": "複製連結",
|
"photo.share.copy.link": "複製連結",
|
||||||
"photo.share.default.title": "照片分享",
|
"photo.share.default.title": "照片分享",
|
||||||
|
"photo.share.download.original": "下載原圖",
|
||||||
|
"photo.share.downloadPreview": "下載預覽圖",
|
||||||
"photo.share.embed.code": "嵌入代碼",
|
"photo.share.embed.code": "嵌入代碼",
|
||||||
"photo.share.embed.copied": "嵌入代碼已複製到剪貼簿",
|
"photo.share.embed.copied": "嵌入代碼已複製到剪貼簿",
|
||||||
"photo.share.embed.description": "複製此代碼以在您的網站中嵌入照片",
|
"photo.share.embed.description": "複製此代碼以在您的網站中嵌入照片",
|
||||||
"photo.share.link.copied": "連結已複製到剪貼簿",
|
"photo.share.link": "分享連結",
|
||||||
|
"photo.share.linkCopied": "連結已複製到剪貼簿",
|
||||||
|
"photo.share.preview": "分享預覽圖",
|
||||||
"photo.share.social.media": "社交媒體",
|
"photo.share.social.media": "社交媒體",
|
||||||
"photo.share.system": "系統分享",
|
"photo.share.system": "系統分享",
|
||||||
"photo.share.text": "看看這張漂亮的照片:{{title}}",
|
"photo.share.text": "看看這張漂亮的照片:{{title}}",
|
||||||
|
|||||||
@@ -346,10 +346,14 @@
|
|||||||
"photo.share.copy.failed": "複製失敗",
|
"photo.share.copy.failed": "複製失敗",
|
||||||
"photo.share.copy.link": "複製連結",
|
"photo.share.copy.link": "複製連結",
|
||||||
"photo.share.default.title": "照片分享",
|
"photo.share.default.title": "照片分享",
|
||||||
|
"photo.share.download.original": "下載原圖",
|
||||||
|
"photo.share.downloadPreview": "下載預覽圖",
|
||||||
"photo.share.embed.code": "嵌入代碼",
|
"photo.share.embed.code": "嵌入代碼",
|
||||||
"photo.share.embed.copied": "嵌入代碼已複製到剪貼簿",
|
"photo.share.embed.copied": "嵌入代碼已複製到剪貼簿",
|
||||||
"photo.share.embed.description": "複製此代碼以在您的網站中嵌入照片",
|
"photo.share.embed.description": "複製此代碼以在您的網站中嵌入照片",
|
||||||
"photo.share.link.copied": "連結已複製到剪貼簿",
|
"photo.share.link": "分享連結",
|
||||||
|
"photo.share.linkCopied": "連結已複製到剪貼簿",
|
||||||
|
"photo.share.preview": "分享預覽圖",
|
||||||
"photo.share.social.media": "社群媒體",
|
"photo.share.social.media": "社群媒體",
|
||||||
"photo.share.system": "系統分享",
|
"photo.share.system": "系統分享",
|
||||||
"photo.share.text": "看看這張漂亮的照片:{{title}}",
|
"photo.share.text": "看看這張漂亮的照片:{{title}}",
|
||||||
|
|||||||
Reference in New Issue
Block a user