feat(blobbi): implement Blobbi Photo (Polaroid) feature

Add the ability to take polaroid-style photos of Blobbis and share them:

- Add lookMode prop to Blobbi rendering system:
  - 'follow-pointer': Eyes track mouse cursor (default, existing behavior)
  - 'forward': Eyes look straight ahead (for photos/export)
  - Updated useBlobbiEyes, BlobbiBabyVisual, BlobbiAdultVisual, BlobbiStageVisual

- Create BlobbiPolaroidCard component:
  - Classic polaroid-style frame with white background and shadow
  - Soft gradient background for photo area
  - Caption area with Blobbi name, stage, and date
  - Fixed dimensions (320x400) for consistent export
  - Built with HTML+CSS (not canvas) for easy customization

- Create BlobbiPhotoModal component:
  - Opens from 'Take a Photo' button on BlobbiPage
  - Shows polaroid preview with Blobbi looking forward
  - Download button: exports as PNG using html-to-image
  - Post button: uploads to Blossom and creates kind 1 note
  - Clean, minimal UI focused on the photo

- Wire up to BlobbiPage:
  - Photo modal state and handler
  - Connected to floating 'Take a Photo' action button

Dependencies:
- Added html-to-image for DOM-to-PNG conversion
This commit is contained in:
filemon
2026-03-21 20:29:03 -03:00
parent 8476edd18f
commit b51535bfa0
9 changed files with 459 additions and 20 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+5 -2
View File
@@ -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<HTMLDivElement>(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
+5 -2
View File
@@ -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<HTMLDivElement>(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
+262
View File
@@ -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<HTMLDivElement>(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<string | null> => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Camera className="size-5" />
Take a Photo
</DialogTitle>
<DialogDescription>
Capture a polaroid-style photo of {companion.name}
</DialogDescription>
</DialogHeader>
{/* Polaroid preview - centered */}
<div className="flex justify-center py-4">
<BlobbiPolaroidCard
ref={polaroidRef}
companion={companion}
showStage
/>
</div>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row gap-3">
<Button
variant="outline"
onClick={handleDownload}
disabled={isProcessing}
className="flex-1"
>
{isGenerating ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Download className="size-4 mr-2" />
)}
Download
</Button>
<Button
onClick={handlePost}
disabled={isProcessing || !user}
className="flex-1"
>
{isPosting ? (
<Loader2 className="size-4 mr-2 animate-spin" />
) : (
<Send className="size-4 mr-2" />
)}
Post
</Button>
</div>
{/* Login hint if not logged in */}
{!user && (
<p className="text-sm text-muted-foreground text-center">
Log in to post your Blobbi photo
</p>
)}
</DialogContent>
</Dialog>
);
}
+131
View File
@@ -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<HTMLDivElement, BlobbiPolaroidCardProps>(
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 (
<div
ref={ref}
className={cn(
'relative flex flex-col',
'bg-[#fefefe] dark:bg-[#f5f5f0]', // Off-white background
'rounded-sm',
'shadow-lg',
className
)}
style={{
width: CARD_WIDTH,
height: CARD_HEIGHT,
}}
>
{/* Photo area with gradient background */}
<div
className="relative flex items-center justify-center overflow-hidden"
style={{
height: PHOTO_AREA_HEIGHT,
// Soft gradient background
background: 'linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 50%, #ddd6fe 100%)',
}}
>
{/* Subtle vignette overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
background: 'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.08) 100%)',
}}
/>
{/* Blobbi visual - forward looking for photo */}
<BlobbiStageVisual
companion={companion}
size="lg"
animated={false} // No animations for photo capture
lookMode="forward" // Eyes look straight ahead
className="size-48 z-10"
/>
</div>
{/* Caption area (thicker bottom like classic polaroid) */}
<div
className="flex flex-col items-center justify-center px-4"
style={{ height: CAPTION_AREA_HEIGHT }}
>
{/* Blobbi name */}
<p
className="text-xl font-medium text-gray-800 dark:text-gray-800 text-center truncate max-w-full"
style={{
fontFamily: "'Permanent Marker', 'Comic Sans MS', cursive, sans-serif",
letterSpacing: '0.02em',
}}
>
{displayCaption}
</p>
{/* Optional stage badge */}
{showStage && (
<span className="mt-1.5 text-xs text-gray-500 dark:text-gray-500 uppercase tracking-wider">
{stageLabel}
</span>
)}
{/* Optional date or decorative element */}
<div className="mt-2 text-[10px] text-gray-400 dark:text-gray-400">
{new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
</div>
);
}
);
+8
View File
@@ -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({
<BlobbiBabyVisual
blobbi={blobbiForVisual}
reaction={effectiveReaction}
lookMode={lookMode}
className="size-full"
/>
<FloatingMusicNotes active={showMusicNotes} />
@@ -177,6 +184,7 @@ export function BlobbiStageVisual({
<BlobbiAdultVisual
blobbi={blobbiForVisual}
reaction={effectiveReaction}
lookMode={lookMode}
className="size-full"
/>
<FloatingMusicNotes active={showMusicNotes} />
+30 -15
View File
@@ -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<HTMLDivElement | null>,
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<number | null>(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]);
}
+10 -1
View File
@@ -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({
<BlobbiDashboardFloatingControls
stage={companion.stage}
onSetAsCompanion={() => 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 */}
<BlobbiPhotoModal
open={showPhotoModal}
onOpenChange={setShowPhotoModal}
companion={companion}
/>
{/* Start Incubation Confirmation Dialog */}
<StartIncubationDialog
open={showIncubationDialog}