Initial commit: Velocity-OS migration

This commit is contained in:
2026-05-01 12:32:19 +05:30
commit 407af828d4
283 changed files with 207782 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import styles from './ReimaginePanel.module.css';
/**
* ReimaginePanel
* One-tap AI room staging. Replaces the raw Dream Weaver tab.
* UX Master Plan §4.4 + Part 2 §2.4 spec:
*
* - Tap "Reimagine" → 3 style tiles stagger-reveal (50ms intervals)
* - Tiles show AI-representative preview images, not labels
* - Select style → "Generate" materializes with spring
* - Generation → property view blurs + shimmer sweeps
* - Result → cross-fade, two actions: Use This / Try Another
*
* All ComfyUI machinery hidden. No job IDs, no queue position.
* User sees: "Reimagining your space…"
*/
type StylePreset = 'modern-luxury' | 'warm-contemporary' | 'minimalist-zen';
type Phase = 'idle' | 'selecting' | 'generating' | 'result';
interface ReimagineResult {
imageUrl: string;
jobId: string; // internal only, never shown
}
interface ReimaginePanelProps {
propertyId: string;
roomImageUrl?: string; // Source room photo
onResultSaved?: (url: string) => void;
}
const STYLE_PRESETS: { id: StylePreset; label: string; previewUrl: string }[] = [
{
id: 'modern-luxury',
label: 'Modern Luxury',
// Curated representative preview images
previewUrl: '/assets/style-previews/modern-luxury.jpg',
},
{
id: 'warm-contemporary',
label: 'Warm Contemporary',
previewUrl: '/assets/style-previews/warm-contemporary.jpg',
},
{
id: 'minimalist-zen',
label: 'Minimalist Zen',
previewUrl: '/assets/style-previews/minimalist-zen.jpg',
},
];
export function ReimaginePanel({
propertyId,
roomImageUrl,
onResultSaved,
}: ReimaginePanelProps) {
const [phase, setPhase] = useState<Phase>('idle');
const [selectedStyle, setSelectedStyle] = useState<StylePreset | null>(null);
const [result, setResult] = useState<ReimagineResult | null>(null);
const handleReimagineClick = () => setPhase('selecting');
const handleStyleSelect = (style: StylePreset) => {
setSelectedStyle(style);
};
const handleGenerate = useCallback(async () => {
if (!selectedStyle) return;
setPhase('generating');
try {
// POST to Dream Weaver gateway (hidden from user)
const resp = await fetch('/dream-weaver', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
property_id: propertyId,
style_preset: selectedStyle,
source_image_url: roomImageUrl,
}),
});
const { job_id } = await resp.json();
// Poll silently (user sees shimmer, not polling)
const imageUrl = await pollForResult(job_id);
setResult({ imageUrl, jobId: job_id });
setPhase('result');
} catch {
setPhase('selecting');
}
}, [selectedStyle, propertyId, roomImageUrl]);
const handleUseThis = async () => {
if (!result) return;
// Save to property media library + generate vault link (handled by Studio)
onResultSaved?.(result.imageUrl);
setPhase('idle');
setResult(null);
setSelectedStyle(null);
};
const handleTryAnother = () => {
setPhase('selecting');
setResult(null);
setSelectedStyle(null);
};
return (
<div className={styles.root}>
{/* ── Idle: single button ─────────────────────────── */}
{phase === 'idle' && (
<motion.button
className={`btn-primary ${styles.reimagineBtn}`}
onClick={handleReimagineClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Reimagine
</motion.button>
)}
{/* ── Selecting: staggered style tiles ─────────────── */}
{(phase === 'selecting' || phase === 'result') && (
<div className={styles.presetsSection}>
<div className={styles.presetGrid}>
{STYLE_PRESETS.map(({ id, label, previewUrl }, idx) => (
<motion.button
key={id}
className={`${styles.presetTile} ${selectedStyle === id ? styles.selected : ''}`}
onClick={() => handleStyleSelect(id)}
initial={{ opacity: 0, scale: 0.92 }}
animate={{
opacity: selectedStyle && selectedStyle !== id ? 0.4 : 1,
scale: selectedStyle === id ? 1.04 : 1,
}}
transition={{
delay: idx * 0.05,
duration: 0.25,
ease: [0.4, 0, 0.2, 1],
}}
aria-pressed={selectedStyle === id}
aria-label={`Style: ${label}`}
>
<div className={styles.previewImg}>
<img src={previewUrl} alt={label} />
</div>
<span className={styles.presetLabel}>{label}</span>
{/* Active glow border */}
{selectedStyle === id && (
<motion.div
layoutId="preset-selection"
className={styles.selectionRing}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
</motion.button>
))}
</div>
{/* Generate button materializes when style selected */}
<AnimatePresence>
{selectedStyle && phase !== 'result' && (
<motion.button
className={`btn-primary ${styles.generateBtn}`}
onClick={handleGenerate}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 380, damping: 25 }}
>
Generate
</motion.button>
)}
</AnimatePresence>
</div>
)}
{/* ── Generating: shimmer overlay ──────────────────── */}
{phase === 'generating' && (
<motion.div
className={styles.generatingState}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className={`${styles.imageFrame} shimmer`} />
<motion.p
className={styles.generatingText}
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{ duration: 2, repeat: Infinity }}
>
Reimagining your space
</motion.p>
</motion.div>
)}
{/* ── Result: cross-fade to generated image ─────────── */}
{phase === 'result' && result && (
<motion.div
className={styles.resultState}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
>
<div className={styles.resultImage}>
<img src={result.imageUrl} alt="AI-generated staging" />
</div>
<div className={styles.resultActions}>
<button className="btn-primary" onClick={handleUseThis}>
Use This
</button>
<button className="btn-ghost" onClick={handleTryAnother}>
Try Another Style
</button>
</div>
</motion.div>
)}
</div>
);
}
// ── Internal: silent polling (hidden from user) ───────────────
async function pollForResult(jobId: string): Promise<string> {
const MAX_ATTEMPTS = 60;
const INTERVAL_MS = 5000;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
await new Promise(r => setTimeout(r, INTERVAL_MS));
const resp = await fetch(`/dream-weaver/status/${jobId}`);
const { status, result_url } = await resp.json();
if (status === 'complete' && result_url) return result_url;
if (status === 'failed') throw new Error('Generation failed');
}
throw new Error('Generation timed out');
}