643 lines
22 KiB
TypeScript
643 lines
22 KiB
TypeScript
import React from 'react';
|
|
import { Sheet } from '@tamagui/sheet';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Button } from '@tamagui/button';
|
|
import { Copy, Download, Loader2, MessageSquare, RefreshCcw, Share2, Sparkles, Wand2, X } from 'lucide-react';
|
|
import { useTranslation } from '@/shared/guest/i18n/useTranslation';
|
|
import { useGuestThemeVariant } from '../lib/guestTheme';
|
|
import {
|
|
createGuestAiEdit,
|
|
fetchGuestAiEditStatus,
|
|
fetchGuestAiStyles,
|
|
type GuestAiEditRequest,
|
|
type GuestAiStyle,
|
|
} from '../services/aiEditsApi';
|
|
import type { ApiError } from '../services/apiClient';
|
|
import { pushGuestToast } from '../lib/toast';
|
|
|
|
type AiMagicEditSheetProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
eventToken: string | null;
|
|
photoId: number | null;
|
|
originalImageUrl: string | null;
|
|
};
|
|
|
|
const POLLABLE_STATUSES = new Set(['queued', 'processing']);
|
|
const MAX_POLL_ATTEMPTS = 72;
|
|
const POLL_INTERVAL_MS = 2500;
|
|
|
|
function resolveErrorMessage(error: unknown, fallback: string): string {
|
|
const apiError = error as ApiError;
|
|
|
|
if (typeof apiError?.message === 'string' && apiError.message.trim() !== '') {
|
|
return apiError.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function buildIdempotencyKey(photoId: number): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return `guest-ai-${photoId}-${crypto.randomUUID()}`;
|
|
}
|
|
|
|
return `guest-ai-${photoId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
function resolveOutputUrl(request: GuestAiEditRequest | null): string | null {
|
|
if (!request || !Array.isArray(request.outputs)) {
|
|
return null;
|
|
}
|
|
|
|
const normalizeStorageUrl = (storagePath?: string | null): string | null => {
|
|
if (!storagePath || typeof storagePath !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
if (/^https?:/i.test(storagePath)) {
|
|
return storagePath;
|
|
}
|
|
|
|
const cleanPath = storagePath.replace(/^\/+/g, '');
|
|
if (cleanPath.startsWith('storage/')) {
|
|
return `/${cleanPath}`;
|
|
}
|
|
|
|
return `/storage/${cleanPath}`;
|
|
};
|
|
|
|
const primary = request.outputs.find(
|
|
(output) =>
|
|
output.is_primary
|
|
&& (
|
|
(typeof output.provider_url === 'string' && output.provider_url)
|
|
|| (typeof output.storage_path === 'string' && output.storage_path)
|
|
)
|
|
);
|
|
if (primary?.provider_url) {
|
|
return primary.provider_url;
|
|
}
|
|
if (primary?.storage_path) {
|
|
return normalizeStorageUrl(primary.storage_path);
|
|
}
|
|
|
|
const first = request.outputs.find(
|
|
(output) =>
|
|
(typeof output.provider_url === 'string' && output.provider_url)
|
|
|| (typeof output.storage_path === 'string' && output.storage_path)
|
|
);
|
|
|
|
if (first?.provider_url) {
|
|
return first.provider_url;
|
|
}
|
|
|
|
return normalizeStorageUrl(first?.storage_path);
|
|
}
|
|
|
|
export default function AiMagicEditSheet({
|
|
open,
|
|
onOpenChange,
|
|
eventToken,
|
|
photoId,
|
|
originalImageUrl,
|
|
}: AiMagicEditSheetProps) {
|
|
const { t } = useTranslation();
|
|
const { isDark } = useGuestThemeVariant();
|
|
const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
|
const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
|
|
|
const [styles, setStyles] = React.useState<GuestAiStyle[]>([]);
|
|
const [stylesLoading, setStylesLoading] = React.useState(false);
|
|
const [stylesError, setStylesError] = React.useState<string | null>(null);
|
|
const [selectedStyleKey, setSelectedStyleKey] = React.useState<string | null>(null);
|
|
const [request, setRequest] = React.useState<GuestAiEditRequest | null>(null);
|
|
const [requestError, setRequestError] = React.useState<string | null>(null);
|
|
const [submitting, setSubmitting] = React.useState(false);
|
|
const [isOnline, setIsOnline] = React.useState<boolean>(() => {
|
|
if (typeof navigator === 'undefined') {
|
|
return true;
|
|
}
|
|
|
|
return navigator.onLine;
|
|
});
|
|
const pollAttemptsRef = React.useRef(0);
|
|
|
|
const selectedStyle = React.useMemo(() => {
|
|
if (!selectedStyleKey) {
|
|
return null;
|
|
}
|
|
|
|
return styles.find((style) => style.key === selectedStyleKey) ?? null;
|
|
}, [selectedStyleKey, styles]);
|
|
|
|
const outputUrl = React.useMemo(() => resolveOutputUrl(request), [request]);
|
|
|
|
const resetRequestState = React.useCallback(() => {
|
|
setRequest(null);
|
|
setRequestError(null);
|
|
setSubmitting(false);
|
|
pollAttemptsRef.current = 0;
|
|
}, []);
|
|
|
|
const loadStyles = React.useCallback(async () => {
|
|
if (!eventToken || !photoId) {
|
|
return;
|
|
}
|
|
|
|
setStylesLoading(true);
|
|
setStylesError(null);
|
|
|
|
try {
|
|
const payload = await fetchGuestAiStyles(eventToken);
|
|
const nextStyles = Array.isArray(payload.data) ? payload.data : [];
|
|
setStyles(nextStyles);
|
|
if (nextStyles.length > 0) {
|
|
setSelectedStyleKey(nextStyles[0]?.key ?? null);
|
|
} else {
|
|
setSelectedStyleKey(null);
|
|
setStylesError(t('galleryPage.lightbox.aiMagicEditNoStyles', 'No AI styles are currently available.'));
|
|
}
|
|
} catch (error) {
|
|
setStyles([]);
|
|
setSelectedStyleKey(null);
|
|
setStylesError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStylesFailed', 'AI styles could not be loaded.')));
|
|
} finally {
|
|
setStylesLoading(false);
|
|
}
|
|
}, [eventToken, photoId, t]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
resetRequestState();
|
|
return;
|
|
}
|
|
|
|
if (!eventToken || !photoId) {
|
|
setStyles([]);
|
|
setSelectedStyleKey(null);
|
|
setStylesError(t('galleryPage.lightbox.aiMagicEditUnavailable', 'AI Magic Edit is currently unavailable.'));
|
|
return;
|
|
}
|
|
|
|
resetRequestState();
|
|
void loadStyles();
|
|
}, [eventToken, loadStyles, open, photoId, resetRequestState, t]);
|
|
|
|
React.useEffect(() => {
|
|
const handleOnline = () => {
|
|
setIsOnline(true);
|
|
};
|
|
const handleOffline = () => {
|
|
setIsOnline(false);
|
|
};
|
|
|
|
window.addEventListener('online', handleOnline);
|
|
window.addEventListener('offline', handleOffline);
|
|
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('offline', handleOffline);
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!open || !eventToken || !request || !POLLABLE_STATUSES.has(request.status) || !isOnline) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const timer = window.setTimeout(async () => {
|
|
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
|
setRequestError(t('galleryPage.lightbox.aiMagicEditPollingTimeout', 'AI generation took too long. Please try again.'));
|
|
return;
|
|
}
|
|
|
|
pollAttemptsRef.current += 1;
|
|
|
|
try {
|
|
const payload = await fetchGuestAiEditStatus(eventToken, request.id);
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setRequest(payload.data);
|
|
if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) {
|
|
setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.'));
|
|
}
|
|
} catch (error) {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
setIsOnline(false);
|
|
return;
|
|
}
|
|
|
|
setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStatusFailed', 'AI status could not be refreshed.')));
|
|
}
|
|
}, POLL_INTERVAL_MS);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(timer);
|
|
};
|
|
}, [eventToken, isOnline, open, request, t]);
|
|
|
|
const startAiEdit = React.useCallback(async () => {
|
|
if (!eventToken || !photoId || !selectedStyleKey) {
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
setRequestError(null);
|
|
|
|
try {
|
|
const payload = await createGuestAiEdit(eventToken, photoId, {
|
|
style_key: selectedStyleKey,
|
|
idempotency_key: buildIdempotencyKey(photoId),
|
|
metadata: {
|
|
client: 'guest-v2',
|
|
entrypoint: 'lightbox',
|
|
},
|
|
});
|
|
|
|
pollAttemptsRef.current = 0;
|
|
setRequest(payload.data);
|
|
if (payload.data.status === 'succeeded' && !resolveOutputUrl(payload.data)) {
|
|
setRequestError(t('galleryPage.lightbox.aiMagicEditResultMissing', 'The AI result could not be loaded.'));
|
|
}
|
|
} catch (error) {
|
|
setRequestError(resolveErrorMessage(error, t('galleryPage.lightbox.aiMagicEditStartFailed', 'AI edit could not be started.')));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}, [eventToken, photoId, selectedStyleKey, t]);
|
|
|
|
const downloadGenerated = React.useCallback(() => {
|
|
if (!outputUrl || !request?.id) {
|
|
return;
|
|
}
|
|
|
|
const link = document.createElement('a');
|
|
link.href = outputUrl;
|
|
link.download = `ai-edit-${request.id}.jpg`;
|
|
link.rel = 'noreferrer';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}, [outputUrl, request?.id]);
|
|
|
|
const copyGeneratedLink = React.useCallback(async () => {
|
|
if (!outputUrl) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard?.writeText(outputUrl);
|
|
pushGuestToast({ text: t('share.copySuccess', 'Link copied!') });
|
|
} catch (error) {
|
|
console.error('Copy generated link failed', error);
|
|
pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' });
|
|
}
|
|
}, [outputUrl, t]);
|
|
|
|
const shareGeneratedNative = React.useCallback(() => {
|
|
if (!outputUrl) {
|
|
return;
|
|
}
|
|
|
|
const shareData: ShareData = {
|
|
title: t('galleryPage.lightbox.aiMagicEditShareTitle', 'AI Magic Edit'),
|
|
text: t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!'),
|
|
url: outputUrl,
|
|
};
|
|
|
|
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
|
navigator.share(shareData).catch(() => undefined);
|
|
return;
|
|
}
|
|
|
|
void copyGeneratedLink();
|
|
}, [copyGeneratedLink, outputUrl, t]);
|
|
|
|
const shareGeneratedWhatsApp = React.useCallback(() => {
|
|
if (!outputUrl) {
|
|
return;
|
|
}
|
|
|
|
const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!');
|
|
const waUrl = `https://wa.me/?text=${encodeURIComponent(`${text} ${outputUrl}`)}`;
|
|
window.open(waUrl, '_blank', 'noopener');
|
|
}, [outputUrl, t]);
|
|
|
|
const shareGeneratedMessages = React.useCallback(() => {
|
|
if (!outputUrl) {
|
|
return;
|
|
}
|
|
|
|
const text = t('galleryPage.lightbox.aiMagicEditShareText', 'Check out my AI edited photo!');
|
|
const smsUrl = `sms:?&body=${encodeURIComponent(`${text} ${outputUrl}`)}`;
|
|
window.open(smsUrl, '_blank', 'noopener');
|
|
}, [outputUrl, t]);
|
|
|
|
const isProcessing = Boolean(request && POLLABLE_STATUSES.has(request.status));
|
|
const isDone = request?.status === 'succeeded' && Boolean(outputUrl);
|
|
|
|
const content = (
|
|
<YStack gap="$3">
|
|
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
|
<XStack alignItems="center" gap="$2">
|
|
<Wand2 size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<YStack>
|
|
<Text fontSize="$4" fontWeight="$8">
|
|
{t('galleryPage.lightbox.aiMagicEdit', 'AI Magic Edit')}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{t('galleryPage.lightbox.aiMagicEditSubtitle', 'Choose a style and generate an AI version.')}
|
|
</Text>
|
|
</YStack>
|
|
</XStack>
|
|
<Button
|
|
size="$3"
|
|
circular
|
|
backgroundColor={mutedSurface}
|
|
borderWidth={1}
|
|
borderColor={mutedBorder}
|
|
onPress={() => onOpenChange(false)}
|
|
aria-label={t('common.actions.close', 'Close')}
|
|
>
|
|
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
</Button>
|
|
</XStack>
|
|
|
|
{stylesLoading ? (
|
|
<XStack alignItems="center" gap="$2" paddingVertical="$2">
|
|
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" color="$color" opacity={0.8}>
|
|
{t('galleryPage.lightbox.aiMagicEditLoadingStyles', 'Loading styles...')}
|
|
</Text>
|
|
</XStack>
|
|
) : null}
|
|
|
|
{stylesError ? (
|
|
<YStack gap="$2" padding="$3" borderRadius="$card" backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
|
|
<Text fontSize="$2" color="#FCA5A5">{stylesError}</Text>
|
|
<XStack gap="$2">
|
|
<Button onPress={() => void loadStyles()} backgroundColor={mutedSurface} borderWidth={1} borderColor={mutedBorder}>
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$6">{t('common.actions.retry', 'Retry')}</Text>
|
|
</XStack>
|
|
</Button>
|
|
</XStack>
|
|
</YStack>
|
|
) : null}
|
|
|
|
{!stylesLoading && !stylesError && !request ? (
|
|
<YStack gap="$3">
|
|
<YStack gap="$2">
|
|
<Text fontSize="$2" fontWeight="$7">
|
|
{t('galleryPage.lightbox.aiMagicEditSelectStyle', 'Select style')}
|
|
</Text>
|
|
<XStack gap="$2" flexWrap="wrap">
|
|
{styles.map((style) => {
|
|
const selected = style.key === selectedStyleKey;
|
|
|
|
return (
|
|
<Button
|
|
key={style.key}
|
|
size="$3"
|
|
backgroundColor={selected ? '$primary' : mutedSurface}
|
|
borderWidth={1}
|
|
borderColor={selected ? '$primary' : mutedBorder}
|
|
onPress={() => setSelectedStyleKey(style.key)}
|
|
>
|
|
<Text fontSize="$2" fontWeight="$6" color={selected ? '#FFFFFF' : undefined}>
|
|
{style.name}
|
|
</Text>
|
|
</Button>
|
|
);
|
|
})}
|
|
</XStack>
|
|
{selectedStyle?.description ? (
|
|
<Text fontSize="$1" color="$color" opacity={0.75}>
|
|
{selectedStyle.description}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
|
|
{originalImageUrl ? (
|
|
<YStack gap="$1">
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{t('galleryPage.lightbox.aiMagicEditOriginalPreview', 'Original photo')}
|
|
</Text>
|
|
<img
|
|
src={originalImageUrl}
|
|
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
|
|
style={{
|
|
width: '100%',
|
|
maxHeight: 180,
|
|
objectFit: 'cover',
|
|
borderRadius: 12,
|
|
border: `1px solid ${mutedBorder}`,
|
|
}}
|
|
/>
|
|
</YStack>
|
|
) : null}
|
|
|
|
<Button
|
|
backgroundColor="$primary"
|
|
onPress={() => void startAiEdit()}
|
|
disabled={!selectedStyleKey || submitting}
|
|
>
|
|
<XStack alignItems="center" gap="$2">
|
|
{submitting ? <Loader2 size={14} className="animate-spin" color="#FFFFFF" /> : <Sparkles size={14} color="#FFFFFF" />}
|
|
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
|
{submitting
|
|
? t('galleryPage.lightbox.aiMagicEditStarting', 'Starting...')
|
|
: t('galleryPage.lightbox.aiMagicEditGenerate', 'Generate AI edit')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
</YStack>
|
|
) : null}
|
|
|
|
{request ? (
|
|
<YStack gap="$3">
|
|
<YStack gap="$1">
|
|
<Text fontSize="$2" fontWeight="$7">
|
|
{request.style?.name ?? t('galleryPage.lightbox.aiMagicEditResultTitle', 'Result')}
|
|
</Text>
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{isProcessing
|
|
? t('galleryPage.lightbox.aiMagicEditProcessing', 'Generating your AI edit...')
|
|
: request.status === 'succeeded'
|
|
? t('galleryPage.lightbox.aiMagicEditReady', 'Your AI edit is ready.')
|
|
: t('galleryPage.lightbox.aiMagicEditFailed', 'AI edit could not be completed.')}
|
|
</Text>
|
|
</YStack>
|
|
|
|
{isProcessing ? (
|
|
<XStack alignItems="center" gap="$2">
|
|
<Loader2 size={16} className="animate-spin" color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" color="$color" opacity={0.8}>
|
|
{t('galleryPage.lightbox.aiMagicEditProcessingHint', 'This can take a few seconds.')}
|
|
</Text>
|
|
</XStack>
|
|
) : null}
|
|
{isProcessing && !isOnline ? (
|
|
<Text fontSize="$2" color="$color" opacity={0.8}>
|
|
{t(
|
|
'galleryPage.lightbox.aiMagicEditOfflineHint',
|
|
'You are offline. Status updates resume automatically when connection is back.'
|
|
)}
|
|
</Text>
|
|
) : null}
|
|
|
|
{isDone && originalImageUrl ? (
|
|
<XStack gap="$2" flexWrap="wrap">
|
|
<YStack flex={1} minWidth={150} gap="$1">
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{t('galleryPage.lightbox.aiMagicEditOriginalLabel', 'Original')}
|
|
</Text>
|
|
<img
|
|
src={originalImageUrl}
|
|
alt={t('galleryPage.lightbox.aiMagicEditOriginalAlt', 'Original photo')}
|
|
style={{
|
|
width: '100%',
|
|
maxHeight: 220,
|
|
objectFit: 'cover',
|
|
borderRadius: 12,
|
|
border: `1px solid ${mutedBorder}`,
|
|
}}
|
|
/>
|
|
</YStack>
|
|
<YStack flex={1} minWidth={150} gap="$1">
|
|
<Text fontSize="$1" color="$color" opacity={0.7}>
|
|
{t('galleryPage.lightbox.aiMagicEditGeneratedLabel', 'AI result')}
|
|
</Text>
|
|
<img
|
|
src={outputUrl}
|
|
alt={t('galleryPage.lightbox.aiMagicEditGeneratedAlt', 'AI generated photo')}
|
|
style={{
|
|
width: '100%',
|
|
maxHeight: 220,
|
|
objectFit: 'cover',
|
|
borderRadius: 12,
|
|
border: `1px solid ${mutedBorder}`,
|
|
}}
|
|
/>
|
|
</YStack>
|
|
</XStack>
|
|
) : null}
|
|
|
|
{requestError ? (
|
|
<Text fontSize="$2" color="#FCA5A5">{requestError}</Text>
|
|
) : null}
|
|
|
|
{(request.status === 'failed' || request.status === 'blocked' || request.status === 'canceled') && request.failure_message ? (
|
|
<Text fontSize="$2" color="#FCA5A5">{request.failure_message}</Text>
|
|
) : null}
|
|
|
|
<XStack gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
|
<Button
|
|
backgroundColor={mutedSurface}
|
|
borderWidth={1}
|
|
borderColor={mutedBorder}
|
|
onPress={resetRequestState}
|
|
>
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<RefreshCcw size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{t('galleryPage.lightbox.aiMagicEditTryAnother', 'Try another style')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
{isDone ? (
|
|
<Button backgroundColor="$primary" onPress={downloadGenerated}>
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<Download size={14} color="#FFFFFF" />
|
|
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
|
|
{t('common.actions.download', 'Download')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
) : null}
|
|
</XStack>
|
|
{isDone ? (
|
|
<XStack gap="$2" flexWrap="wrap">
|
|
<Button
|
|
backgroundColor={mutedSurface}
|
|
borderWidth={1}
|
|
borderColor={mutedBorder}
|
|
onPress={shareGeneratedNative}
|
|
>
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{t('share.button', 'Share')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
<Button
|
|
backgroundColor={mutedSurface}
|
|
borderWidth={1}
|
|
borderColor={mutedBorder}
|
|
onPress={shareGeneratedWhatsApp}
|
|
>
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{t('share.whatsapp', 'WhatsApp')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
<Button
|
|
backgroundColor={mutedSurface}
|
|
borderWidth={1}
|
|
borderColor={mutedBorder}
|
|
onPress={shareGeneratedMessages}
|
|
>
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<MessageSquare size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{t('share.imessage', 'Messages')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
<Button
|
|
backgroundColor={mutedSurface}
|
|
borderWidth={1}
|
|
borderColor={mutedBorder}
|
|
onPress={() => void copyGeneratedLink()}
|
|
>
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<Copy size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
|
<Text fontSize="$2" fontWeight="$6">
|
|
{t('share.copyLink', 'Copy link')}
|
|
</Text>
|
|
</XStack>
|
|
</Button>
|
|
</XStack>
|
|
) : null}
|
|
</YStack>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange} snapPoints={[88]} position={open ? 0 : -1} modal>
|
|
<Sheet.Overlay {...({ backgroundColor: isDark ? 'rgba(15, 23, 42, 0.6)' : 'rgba(15, 23, 42, 0.35)' } as any)} />
|
|
<Sheet.Frame padding="$4" backgroundColor="$surface" borderTopLeftRadius="$6" borderTopRightRadius="$6">
|
|
<Sheet.Handle height={5} width={52} backgroundColor="#CBD5E1" borderRadius={999} marginBottom="$3" />
|
|
<YStack style={{ maxHeight: '82vh', overflowY: 'auto' }}>
|
|
{content}
|
|
</YStack>
|
|
</Sheet.Frame>
|
|
</Sheet>
|
|
);
|
|
}
|