diff --git a/package-lock.json b/package-lock.json index 64e4efbd..5416b6b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "emoji-mart": "^5.6.0", "fflate": "^0.8.2", "hls.js": "^1.6.15", + "html-to-image": "^1.11.13", "idb": "^8.0.3", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", @@ -6257,6 +6258,12 @@ "node": ">=18" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", diff --git a/package.json b/package.json index 00be420f..6c3b85e6 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "emoji-mart": "^5.6.0", "fflate": "^0.8.2", "hls.js": "^1.6.15", + "html-to-image": "^1.11.13", "idb": "^8.0.3", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", diff --git a/src/blobbi/ui/BlobbiAdultVisual.tsx b/src/blobbi/ui/BlobbiAdultVisual.tsx index c41b52d0..b6e3d97a 100644 --- a/src/blobbi/ui/BlobbiAdultVisual.tsx +++ b/src/blobbi/ui/BlobbiAdultVisual.tsx @@ -13,7 +13,7 @@ import { resolveAdultSvgWithForm, customizeAdultSvgFromBlobbi } from '@/blobbi/a import { cn } from '@/lib/utils'; import { addEyeAnimation } from './lib/eye-animation'; -import { useBlobbiEyes } from './lib/useBlobbiEyes'; +import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes'; import type { Blobbi } from '@/types/blobbi'; import { isBlobbiSleeping } from '@/types/blobbi'; @@ -29,6 +29,8 @@ export interface BlobbiAdultVisualProps { blobbi: Blobbi; /** Reaction state for music/sing animations */ reaction?: AdultReactionState; + /** Controls eye tracking behavior (default: 'follow-pointer') */ + lookMode?: BlobbiLookMode; /** Additional CSS classes for the container */ className?: string; } @@ -44,7 +46,7 @@ export interface BlobbiAdultVisualProps { * - Eyes always track the mouse cursor (instant, real-time) * - Renders safely using dangerouslySetInnerHTML */ -export function BlobbiAdultVisual({ blobbi, reaction = 'idle', className }: BlobbiAdultVisualProps) { +export function BlobbiAdultVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', className }: BlobbiAdultVisualProps) { const isSleeping = isBlobbiSleeping(blobbi); const containerRef = useRef(null); @@ -56,6 +58,7 @@ export function BlobbiAdultVisual({ blobbi, reaction = 'idle', className }: Blob useBlobbiEyes(containerRef, { isSleeping, maxMovement: 2.5, // Slightly more movement for larger adult form + lookMode, }); // Memoize the customized SVG to avoid unnecessary processing diff --git a/src/blobbi/ui/BlobbiBabyVisual.tsx b/src/blobbi/ui/BlobbiBabyVisual.tsx index 8278b3e1..1751bc7b 100644 --- a/src/blobbi/ui/BlobbiBabyVisual.tsx +++ b/src/blobbi/ui/BlobbiBabyVisual.tsx @@ -10,7 +10,7 @@ import { useMemo, useRef } from 'react'; import { resolveBabySvg, customizeBabySvgFromBlobbi } from '@/blobbi/baby-blobbi'; import { addEyeAnimation } from './lib/eye-animation'; -import { useBlobbiEyes } from './lib/useBlobbiEyes'; +import { useBlobbiEyes, type BlobbiLookMode } from './lib/useBlobbiEyes'; import { cn } from '@/lib/utils'; import type { Blobbi } from '@/types/blobbi'; import { isBlobbiSleeping } from '@/types/blobbi'; @@ -27,6 +27,8 @@ export interface BlobbiBabyVisualProps { blobbi: Blobbi; /** Reaction state for music/sing animations */ reaction?: BabyReactionState; + /** Controls eye tracking behavior (default: 'follow-pointer') */ + lookMode?: BlobbiLookMode; /** Additional CSS classes for the container */ className?: string; } @@ -41,7 +43,7 @@ export interface BlobbiBabyVisualProps { * - Eyes always track the mouse cursor (instant, real-time) * - Renders safely using dangerouslySetInnerHTML */ -export function BlobbiBabyVisual({ blobbi, reaction = 'idle', className }: BlobbiBabyVisualProps) { +export function BlobbiBabyVisual({ blobbi, reaction = 'idle', lookMode = 'follow-pointer', className }: BlobbiBabyVisualProps) { const isSleeping = isBlobbiSleeping(blobbi); const containerRef = useRef(null); @@ -53,6 +55,7 @@ export function BlobbiBabyVisual({ blobbi, reaction = 'idle', className }: Blobb useBlobbiEyes(containerRef, { isSleeping, maxMovement: 2, + lookMode, }); // Memoize the customized SVG to avoid unnecessary processing diff --git a/src/blobbi/ui/BlobbiPhotoModal.tsx b/src/blobbi/ui/BlobbiPhotoModal.tsx new file mode 100644 index 00000000..e27045d5 --- /dev/null +++ b/src/blobbi/ui/BlobbiPhotoModal.tsx @@ -0,0 +1,262 @@ +/** + * BlobbiPhotoModal - Modal for taking and sharing Blobbi photos + * + * Features: + * - Polaroid-style preview of the Blobbi + * - Download as PNG + * - Post to Nostr with Blossom upload + * + * Uses html-to-image for DOM-to-PNG conversion. + */ + +import { useState, useRef, useCallback } from 'react'; +import { toPng } from 'html-to-image'; +import { Download, Send, Loader2, Camera } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { BlobbiPolaroidCard } from './BlobbiPolaroidCard'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { toast } from '@/hooks/useToast'; +import type { BlobbiCompanion } from '@/lib/blobbi'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface BlobbiPhotoModalProps { + /** Whether the modal is open */ + open: boolean; + /** Callback when the modal should close */ + onOpenChange: (open: boolean) => void; + /** The Blobbi companion to photograph */ + companion: BlobbiCompanion; +} + +// ─── Utility Functions ──────────────────────────────────────────────────────── + +/** + * Convert a data URL to a File object + */ +function dataUrlToFile(dataUrl: string, filename: string): File { + const arr = dataUrl.split(','); + const mime = arr[0].match(/:(.*?);/)?.[1] ?? 'image/png'; + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + return new File([u8arr], filename, { type: mime }); +} + +/** + * Trigger a file download in the browser + */ +function downloadFile(dataUrl: string, filename: string): void { + const link = document.createElement('a'); + link.download = filename; + link.href = dataUrl; + link.click(); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function BlobbiPhotoModal({ + open, + onOpenChange, + companion, +}: BlobbiPhotoModalProps) { + const polaroidRef = useRef(null); + const [isGenerating, setIsGenerating] = useState(false); + const [isPosting, setIsPosting] = useState(false); + + const { user } = useCurrentUser(); + const { mutateAsync: uploadFile } = useUploadFile(); + const { mutateAsync: createEvent } = useNostrPublish(); + + /** + * Generate PNG from the polaroid card + */ + const generateImage = useCallback(async (): Promise => { + if (!polaroidRef.current) { + toast({ + variant: 'destructive', + title: 'Error', + description: 'Could not capture the photo. Please try again.', + }); + return null; + } + + try { + // Use html-to-image with high quality settings + const dataUrl = await toPng(polaroidRef.current, { + quality: 1.0, + pixelRatio: 2, // 2x for retina displays + cacheBust: true, + // Skip external fonts that might fail to load + skipFonts: true, + }); + return dataUrl; + } catch (error) { + console.error('[BlobbiPhotoModal] Failed to generate image:', error); + toast({ + variant: 'destructive', + title: 'Error', + description: 'Failed to generate the photo. Please try again.', + }); + return null; + } + }, []); + + /** + * Handle download action + */ + const handleDownload = useCallback(async () => { + setIsGenerating(true); + try { + const dataUrl = await generateImage(); + if (dataUrl) { + const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-polaroid.png`; + downloadFile(dataUrl, filename); + toast({ + title: 'Photo saved!', + description: 'Your Blobbi photo has been downloaded.', + }); + } + } finally { + setIsGenerating(false); + } + }, [generateImage, companion.name]); + + /** + * Handle post action - upload to Blossom and create Nostr post + */ + const handlePost = useCallback(async () => { + if (!user) { + toast({ + variant: 'destructive', + title: 'Not logged in', + description: 'Please log in to post your Blobbi photo.', + }); + return; + } + + setIsPosting(true); + try { + // Generate the image + const dataUrl = await generateImage(); + if (!dataUrl) { + return; + } + + // Convert to File for upload + const filename = `${companion.name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.png`; + const file = dataUrlToFile(dataUrl, filename); + + // Upload to Blossom + const tags = await uploadFile(file); + const [[, url]] = tags; + + // Build imeta tag from NIP-94 tags + const imetaFields = tags.map((tag) => `${tag[0]} ${tag[1]}`); + + // Create the post content + const content = `${companion.name} ${url}`; + + // Publish kind 1 event + await createEvent({ + kind: 1, + content, + tags: [['imeta', ...imetaFields]], + }); + + toast({ + title: 'Posted!', + description: 'Your Blobbi photo has been shared.', + }); + + // Close the modal after successful post + onOpenChange(false); + } catch (error) { + console.error('[BlobbiPhotoModal] Failed to post:', error); + toast({ + variant: 'destructive', + title: 'Failed to post', + description: error instanceof Error ? error.message : 'Please try again.', + }); + } finally { + setIsPosting(false); + } + }, [user, generateImage, companion.name, uploadFile, createEvent, onOpenChange]); + + const isProcessing = isGenerating || isPosting; + + return ( + + + + + + Take a Photo + + + Capture a polaroid-style photo of {companion.name} + + + + {/* Polaroid preview - centered */} +
+ +
+ + {/* Action buttons */} +
+ + + +
+ + {/* Login hint if not logged in */} + {!user && ( +

+ Log in to post your Blobbi photo +

+ )} +
+
+ ); +} diff --git a/src/blobbi/ui/BlobbiPolaroidCard.tsx b/src/blobbi/ui/BlobbiPolaroidCard.tsx new file mode 100644 index 00000000..6d1e0826 --- /dev/null +++ b/src/blobbi/ui/BlobbiPolaroidCard.tsx @@ -0,0 +1,131 @@ +/** + * BlobbiPolaroidCard - Polaroid-style card for Blobbi photos + * + * Renders a Blobbi inside a classic polaroid-style frame with: + * - White/off-white background + * - Subtle shadow + * - Slightly rounded corners + * - Thicker bottom area for name/caption + * + * Built using HTML + CSS (NOT canvas) for easy customization. + * Fixed dimensions ensure consistent export results. + */ + +import { forwardRef } from 'react'; + +import { BlobbiStageVisual } from './BlobbiStageVisual'; +import { cn } from '@/lib/utils'; +import type { BlobbiCompanion } from '@/lib/blobbi'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface BlobbiPolaroidCardProps { + /** The Blobbi companion data */ + companion: BlobbiCompanion; + /** Optional caption text (defaults to Blobbi name) */ + caption?: string; + /** Show stage badge (e.g., "Baby", "Adult") */ + showStage?: boolean; + /** Additional CSS classes for the outer container */ + className?: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +// Fixed dimensions for consistent export (3:4 aspect ratio like classic polaroid) +const CARD_WIDTH = 320; +const CARD_HEIGHT = 400; +const PHOTO_AREA_HEIGHT = 300; +const CAPTION_AREA_HEIGHT = 100; + +// ─── Component ──────────────────────────────────────────────────────────────── + +/** + * Polaroid-style card for Blobbi photos. + * + * Uses forwardRef to allow parent components to capture the DOM node + * for image export using html-to-image. + */ +export const BlobbiPolaroidCard = forwardRef( + function BlobbiPolaroidCard({ companion, caption, showStage = false, className }, ref) { + const displayCaption = caption ?? companion.name; + const stageLabel = companion.stage.charAt(0).toUpperCase() + companion.stage.slice(1); + + return ( +
+ {/* Photo area with gradient background */} +
+ {/* Subtle vignette overlay */} +
+ + {/* Blobbi visual - forward looking for photo */} + +
+ + {/* Caption area (thicker bottom like classic polaroid) */} +
+ {/* Blobbi name */} +

+ {displayCaption} +

+ + {/* Optional stage badge */} + {showStage && ( + + {stageLabel} + + )} + + {/* Optional date or decorative element */} +
+ {new Date().toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +
+
+
+ ); + } +); diff --git a/src/blobbi/ui/BlobbiStageVisual.tsx b/src/blobbi/ui/BlobbiStageVisual.tsx index 6e332d40..d530cf16 100644 --- a/src/blobbi/ui/BlobbiStageVisual.tsx +++ b/src/blobbi/ui/BlobbiStageVisual.tsx @@ -18,6 +18,9 @@ import { FloatingMusicNotes } from './FloatingMusicNotes'; import { cn } from '@/lib/utils'; import type { BlobbiCompanion } from '@/lib/blobbi'; import type { Blobbi } from '@/types/blobbi'; +import type { BlobbiLookMode } from './lib/useBlobbiEyes'; + +export type { BlobbiLookMode }; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -38,6 +41,8 @@ export interface BlobbiStageVisualProps { animated?: boolean; /** Reaction state for music/sing animations */ reaction?: BlobbiReaction; + /** Controls eye tracking behavior (default: 'follow-pointer') */ + lookMode?: BlobbiLookMode; /** Additional CSS classes for the container */ className?: string; } @@ -108,6 +113,7 @@ export function BlobbiStageVisual({ size = 'md', animated = false, reaction = 'idle', + lookMode = 'follow-pointer', className, }: BlobbiStageVisualProps) { const { stage } = companion; @@ -157,6 +163,7 @@ export function BlobbiStageVisual({ @@ -177,6 +184,7 @@ export function BlobbiStageVisual({ diff --git a/src/blobbi/ui/lib/useBlobbiEyes.ts b/src/blobbi/ui/lib/useBlobbiEyes.ts index 954b82aa..e4a4de1e 100644 --- a/src/blobbi/ui/lib/useBlobbiEyes.ts +++ b/src/blobbi/ui/lib/useBlobbiEyes.ts @@ -33,11 +33,20 @@ export interface EyePosition { y: number; } +/** + * Controls how the Blobbi's eyes behave + * - 'follow-pointer': Eyes track the mouse cursor (default) + * - 'forward': Eyes look straight ahead (for photos/export) + */ +export type BlobbiLookMode = 'follow-pointer' | 'forward'; + export interface UseBlobbiEyesOptions { /** Whether the Blobbi is sleeping (disables animation) */ isSleeping?: boolean; /** Maximum eye movement in pixels (default: 2) */ maxMovement?: number; + /** Controls eye tracking behavior (default: 'follow-pointer') */ + lookMode?: BlobbiLookMode; } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -198,7 +207,7 @@ export function useBlobbiEyes( containerRef: React.RefObject, options: UseBlobbiEyesOptions = {} ): void { - const { isSleeping = false, maxMovement = DEFAULT_MAX_MOVEMENT } = options; + const { isSleeping = false, maxMovement = DEFAULT_MAX_MOVEMENT, lookMode = 'follow-pointer' } = options; // Animation frame ref for cleanup const animationRef = useRef(null); @@ -328,22 +337,28 @@ export function useBlobbiEyes( blinkStateRef.current = updateBlinkState(blinkStateRef.current, timestamp); const blinkScaleY = calculateBlinkScale(blinkStateRef.current, timestamp); - // ─── Calculate Mouse Tracking ─────────────────────────────────────── - // Get Blobbi center position - const rect = containerRef.current.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; + // ─── Calculate Eye Position ─────────────────────────────────────── + let eyeX = 0; + let eyeY = 0; - // Calculate direction to mouse - const dx = globalMouseX - centerX; - const dy = globalMouseY - centerY; + if (lookMode === 'follow-pointer') { + // Get Blobbi center position + const rect = containerRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; - // Calculate angle to mouse - const angle = Math.atan2(dy, dx); + // Calculate direction to mouse + const dx = globalMouseX - centerX; + const dy = globalMouseY - centerY; - // Calculate eye position (instant, no interpolation) - const eyeX = Math.cos(angle) * maxMovement; - const eyeY = Math.sin(angle) * maxMovement * VERTICAL_SCALE; + // Calculate angle to mouse + const angle = Math.atan2(dy, dx); + + // Calculate eye position (instant, no interpolation) + eyeX = Math.cos(angle) * maxMovement; + eyeY = Math.sin(angle) * maxMovement * VERTICAL_SCALE; + } + // 'forward' mode: eyes stay at (0, 0) - looking straight ahead // ─── Apply Tracking Transform (pupils only) ────────────────────────── // Only translate - no scale here. Eye whites stay fixed. @@ -390,5 +405,5 @@ export function useBlobbiEyes( cancelAnimationFrame(animationRef.current); } }; - }, [isSleeping, maxMovement, containerRef]); + }, [isSleeping, maxMovement, lookMode, containerRef]); } diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 1f5f525a..263be17b 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -26,6 +26,7 @@ import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual'; +import { BlobbiPhotoModal } from '@/blobbi/ui/BlobbiPhotoModal'; import { cn } from '@/lib/utils'; import { @@ -760,6 +761,7 @@ function BlobbiDashboard({ const [showShopModal, setShowShopModal] = useState(false); const [showInventoryModal, setShowInventoryModal] = useState(false); const [showInfoModal, setShowInfoModal] = useState(false); + const [showPhotoModal, setShowPhotoModal] = useState(false); // Adoption flow modal state const [showAdoptionFlow, setShowAdoptionFlow] = useState(false); @@ -1150,7 +1152,7 @@ function BlobbiDashboard({ console.log('TODO: set as companion')} - onTakePhoto={() => console.log('TODO: take photo')} + onTakePhoto={() => setShowPhotoModal(true)} onOpenPiP={() => console.log('TODO: open PiP')} onEvolve={ // For eggs not yet incubating: show incubation dialog @@ -1441,6 +1443,13 @@ function BlobbiDashboard({ onSuccess={refetchCurrentTasks} /> + {/* Blobbi Photo Modal - polaroid-style photo capture */} + + {/* Start Incubation Confirmation Dialog */}