Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import { Input } from '@/components/ui/input'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { ImageUpload } from '@/components/ui/image-upload'
|
||
import { AudioUpload } from '@/components/ui/audio-upload'
|
||
import { CardVoicesManager } from '@/components/CardVoicesManager'
|
||
|
||
interface CardEditorPreviewProps {
|
||
formData: {
|
||
packId: string
|
||
original: string
|
||
translation: string
|
||
mnemo: string
|
||
transcription: string
|
||
transcriptionMnemo: string
|
||
back: string
|
||
image: string | undefined
|
||
imageBack: string | undefined
|
||
voice: string | undefined
|
||
voiceLanguage: string
|
||
}
|
||
onFormDataChange: (updates: Partial<CardEditorPreviewProps['formData']>) => void
|
||
cardId?: string
|
||
disabled?: boolean
|
||
packColor?: string
|
||
}
|
||
|
||
// Helper to get image source
|
||
// Note: For UUID (objectId), returns undefined - ImageUpload component will handle fetching presigned URL
|
||
function getImageSrc(image?: string): string | undefined {
|
||
if (!image) return undefined
|
||
|
||
// If it's already a data URL or http/https URL, return as is
|
||
if (image.startsWith('data:') || image.startsWith('http://') || image.startsWith('https://')) {
|
||
return image
|
||
}
|
||
|
||
// If it's a relative API path, prepend the base URL
|
||
if (image.startsWith('/api/')) {
|
||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online'
|
||
return `${baseUrl}${image}`
|
||
}
|
||
|
||
// If it's a UUID (objectId), return undefined - ImageUpload will fetch presigned URL
|
||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(image)
|
||
if (isUuid) {
|
||
return undefined // Will be handled by ImageUpload component's useEffect
|
||
}
|
||
|
||
// Otherwise, assume it's base64 and add the data URL prefix
|
||
return `data:image/png;base64,${image}`
|
||
}
|
||
|
||
export function CardEditorPreview({
|
||
formData,
|
||
onFormDataChange,
|
||
cardId,
|
||
disabled = false,
|
||
packColor = '#6b7280', // Default gray border color
|
||
}: CardEditorPreviewProps) {
|
||
|
||
return (
|
||
<div className="flex flex-col items-center space-y-4">
|
||
{/* Cards Preview - Front and Back side by side */}
|
||
<div className="w-full max-w-5xl mx-auto">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{/* Front side */}
|
||
<div className="space-y-2">
|
||
<div className="text-sm font-medium text-center text-muted-foreground">Front Side</div>
|
||
<div
|
||
className="rounded-3xl shadow-lg"
|
||
style={{
|
||
borderColor: packColor,
|
||
backgroundColor: 'white',
|
||
borderWidth: '3px',
|
||
borderStyle: 'solid',
|
||
aspectRatio: '0.66',
|
||
minHeight: '400px',
|
||
maxHeight: '600px',
|
||
}}
|
||
>
|
||
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
|
||
{/* Top section: Original, Translation */}
|
||
<div className="w-full px-6 pt-6 pb-4 flex-shrink-0">
|
||
{/* Original - large, bold */}
|
||
<div className="mb-3">
|
||
<Input
|
||
value={formData.original}
|
||
onChange={(e) =>
|
||
onFormDataChange({ original: e.target.value })
|
||
}
|
||
placeholder="Original text"
|
||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
|
||
disabled={disabled}
|
||
style={{
|
||
fontSize: '32px',
|
||
fontWeight: 700,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Translation - smaller, gray */}
|
||
<div className="mb-3">
|
||
<Input
|
||
value={formData.translation}
|
||
onChange={(e) =>
|
||
onFormDataChange({ translation: e.target.value })
|
||
}
|
||
placeholder="Translation"
|
||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent"
|
||
disabled={disabled}
|
||
style={{
|
||
fontSize: '22px',
|
||
fontWeight: 400,
|
||
color: formData.translation ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Image in the middle */}
|
||
<div className="flex-1 bg-gray-50 flex items-center justify-center relative min-h-0">
|
||
{getImageSrc(formData.image) ? (
|
||
<img
|
||
src={getImageSrc(formData.image)}
|
||
alt="Card"
|
||
className="w-full h-full object-cover"
|
||
onError={(e) => {
|
||
const target = e.target as HTMLImageElement
|
||
target.style.display = 'none'
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="text-gray-400">
|
||
<svg
|
||
className="w-24 h-24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mnemo phrase at the bottom */}
|
||
<div className="w-full px-6 py-6 bg-white flex-shrink-0">
|
||
<div className="text-center min-h-[60px] flex items-center justify-center">
|
||
<Input
|
||
value={formData.mnemo}
|
||
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
|
||
placeholder="Mnemo phrase"
|
||
className="text-center border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 h-auto bg-transparent w-full"
|
||
disabled={disabled}
|
||
style={{
|
||
fontSize: '24px',
|
||
fontWeight: 600,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Back side */}
|
||
<div
|
||
className="rounded-3xl shadow-lg"
|
||
style={{
|
||
borderColor: packColor,
|
||
backgroundColor: 'white',
|
||
borderWidth: '3px',
|
||
borderStyle: 'solid',
|
||
aspectRatio: '0.66',
|
||
minHeight: '400px',
|
||
maxHeight: '600px',
|
||
}}
|
||
>
|
||
<div className="h-full w-full rounded-3xl overflow-hidden flex flex-col">
|
||
{formData.imageBack ? (
|
||
<>
|
||
<div className="flex-1 flex items-center justify-center bg-gray-50 min-h-0">
|
||
<img
|
||
src={getImageSrc(formData.imageBack)}
|
||
alt="Back"
|
||
className="w-full h-full object-contain"
|
||
onError={(e) => {
|
||
const target = e.target as HTMLImageElement
|
||
target.style.display = 'none'
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="p-6 bg-white flex-shrink-0">
|
||
<Textarea
|
||
value={formData.back || formData.original}
|
||
onChange={(e) =>
|
||
onFormDataChange({ back: e.target.value })
|
||
}
|
||
placeholder="Back side text"
|
||
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
|
||
disabled={disabled}
|
||
style={{
|
||
fontSize: '28px',
|
||
fontWeight: 700,
|
||
lineHeight: '1.2',
|
||
}}
|
||
/>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="h-full flex items-center justify-center p-6">
|
||
<Textarea
|
||
value={formData.back || formData.original}
|
||
onChange={(e) =>
|
||
onFormDataChange({ back: e.target.value })
|
||
}
|
||
placeholder="Back side text"
|
||
className="text-center resize-none border-none shadow-none focus-visible:ring-2 focus-visible:ring-primary/20 p-0 min-h-[80px] w-full bg-transparent"
|
||
disabled={disabled}
|
||
style={{
|
||
fontSize: '28px',
|
||
fontWeight: 700,
|
||
lineHeight: '1.2',
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Edit fields below the preview */}
|
||
<div className="w-full max-w-5xl space-y-4 p-4 border rounded-lg bg-muted/30">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">Original *</label>
|
||
<Input
|
||
value={formData.original}
|
||
onChange={(e) =>
|
||
onFormDataChange({ original: e.target.value })
|
||
}
|
||
placeholder="e.g. cerdo"
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">Translation *</label>
|
||
<Input
|
||
value={formData.translation}
|
||
onChange={(e) =>
|
||
onFormDataChange({ translation: e.target.value })
|
||
}
|
||
placeholder="e.g. pig"
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">Mnemo *</label>
|
||
<Input
|
||
value={formData.mnemo}
|
||
onChange={(e) => onFormDataChange({ mnemo: e.target.value })}
|
||
placeholder="e.g. [pig] with heart"
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">Back Side</label>
|
||
<Textarea
|
||
value={formData.back}
|
||
onChange={(e) => onFormDataChange({ back: e.target.value })}
|
||
placeholder="Additional information on the back of the card"
|
||
rows={3}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<ImageUpload
|
||
label="Front Image"
|
||
value={formData.image}
|
||
onChange={(value) => onFormDataChange({ image: value })}
|
||
disabled={disabled}
|
||
uploadType="card-image"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<ImageUpload
|
||
label="Back Image"
|
||
value={formData.imageBack}
|
||
onChange={(value) => onFormDataChange({ imageBack: value })}
|
||
disabled={disabled}
|
||
uploadType="card-image"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">Transcription</label>
|
||
<Input
|
||
value={formData.transcription}
|
||
onChange={(e) =>
|
||
onFormDataChange({ transcription: e.target.value })
|
||
}
|
||
placeholder="e.g. sɛrdo"
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium">Transcription Mnemo</label>
|
||
<Input
|
||
value={formData.transcriptionMnemo}
|
||
onChange={(e) =>
|
||
onFormDataChange({ transcriptionMnemo: e.target.value })
|
||
}
|
||
placeholder="e.g. pig with heart"
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<AudioUpload
|
||
label="Voice (will be added on Save)"
|
||
value={formData.voice}
|
||
onChange={(voice) => onFormDataChange({ voice })}
|
||
language={formData.voiceLanguage}
|
||
onLanguageChange={(voiceLanguage) =>
|
||
onFormDataChange({ voiceLanguage })
|
||
}
|
||
disabled={disabled}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
Выберите аудиофайл — он будет загружен и привязан к карточке при
|
||
нажатии Create/Update.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Voice Controls - only show if cardId exists */}
|
||
{cardId && (
|
||
<div className="space-y-2">
|
||
<CardVoicesManager cardId={cardId} disabled={disabled} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|