Files
recommender/packages/frontend/src/components/NewRecommendationModal.tsx
Jose Henrique 6e9cfc5d30
All checks were successful
Recommender Build and Deploy (internal) / Build Recommender Image (push) Successful in 3m39s
Recommender Build and Deploy (internal) / Deploy Recommender (internal) (push) Successful in 38s
adding continuous
2026-04-17 21:09:59 -03:00

575 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'preact/hooks';
import type { MediaType } from '../types/index.js';
import './Modal.css';
type GenerationMode = 'brainstorm' | 'continuous';
interface NewRecommendationModalProps {
onClose: () => void;
onSubmit: (body: {
main_prompt: string;
liked_shows: string;
disliked_shows: string;
themes: string;
requirements?: string;
avoid?: string;
brainstorm_count?: number;
media_type: MediaType;
use_web_search?: boolean;
use_validator?: boolean;
hard_requirements?: boolean;
self_expansive?: boolean;
expansive_passes?: number;
expansive_mode?: 'soft' | 'extreme';
generation_mode?: GenerationMode;
total_count?: number;
validate_results?: boolean;
}) => Promise<void>;
}
const MEDIA_OPTIONS: Array<{
type: MediaType;
icon: string;
label: string;
description: string;
}> = [
{
type: 'tv_show',
icon: '📺',
label: 'TV Shows',
description: 'Serialized stories, limited series, and long-form comfort watches.',
},
{
type: 'movie',
icon: '🎬',
label: 'Movies',
description: 'Feature films, prestige cinema, and one-night picks.',
},
];
const MODE_OPTIONS: Array<{
mode: GenerationMode;
label: string;
badge: string;
description: string;
}> = [
{
mode: 'brainstorm',
label: 'Brainstorm',
badge: 'Best for variety',
description: 'Explore a broad pool of options, then rank and curate the strongest fits.',
},
{
mode: 'continuous',
label: 'Continuous',
badge: 'Best for deep search',
description: 'Generate recommendations in chained batches for a steadier, longer-running hunt.',
},
];
export function NewRecommendationModal({ onClose, onSubmit }: NewRecommendationModalProps) {
const [step, setStep] = useState<'type' | 'mode' | 'form'>('type');
const [mediaType, setMediaType] = useState<MediaType>('tv_show');
const [generationMode, setGenerationMode] = useState<GenerationMode>('brainstorm');
const [mainPrompt, setMainPrompt] = useState('');
const [likedShows, setLikedShows] = useState('');
const [dislikedShows, setDislikedShows] = useState('');
const [themes, setThemes] = useState('');
const [requirements, setRequirements] = useState('');
const [avoid, setAvoid] = useState('');
const [brainstormCount, setBrainstormCount] = useState(100);
const [totalCount, setTotalCount] = useState(30);
const [useWebSearch, setUseWebSearch] = useState(false);
const [useValidator, setUseValidator] = useState(false);
const [useHardRequirements, setUseHardRequirements] = useState(false);
const [selfExpansive, setSelfExpansive] = useState(false);
const [expansivePasses, setExpansivePasses] = useState(2);
const [expansiveMode, setExpansiveMode] = useState<'soft' | 'extreme'>('soft');
const [validateResults, setValidateResults] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !loading) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [loading, onClose]);
const mediaLabel = mediaType === 'movie' ? 'Movie' : 'TV Show';
const mediaPluralLabel = mediaType === 'movie' ? 'movies' : 'shows';
const handleSelectType = (type: MediaType) => {
setMediaType(type);
setStep('mode');
};
const handleWebSearchToggle = () => {
const next = !useWebSearch;
setUseWebSearch(next);
if (!next) {
setUseValidator(false);
setValidateResults(false);
}
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (generationMode === 'brainstorm' && !mainPrompt.trim()) return;
if (!likedShows.trim()) return;
setLoading(true);
try {
if (generationMode === 'brainstorm') {
await onSubmit({
main_prompt: mainPrompt.trim(),
liked_shows: likedShows.trim(),
disliked_shows: dislikedShows.trim(),
themes: themes.trim(),
brainstorm_count: brainstormCount,
media_type: mediaType,
use_web_search: useWebSearch,
use_validator: useValidator,
hard_requirements: useHardRequirements,
self_expansive: selfExpansive,
expansive_passes: selfExpansive ? expansivePasses : 1,
expansive_mode: expansiveMode,
generation_mode: 'brainstorm',
});
} else {
await onSubmit({
main_prompt: '',
liked_shows: likedShows.trim(),
disliked_shows: dislikedShows.trim(),
themes: themes.trim(),
requirements: requirements.trim(),
avoid: avoid.trim(),
media_type: mediaType,
use_web_search: useWebSearch,
validate_results: validateResults,
generation_mode: 'continuous',
total_count: totalCount,
});
}
onClose();
} finally {
setLoading(false);
}
};
const handleBackdropClick = (e: MouseEvent) => {
if ((e.target as HTMLElement).classList.contains('modal-backdrop') && !loading) {
onClose();
}
};
const selectedMode = MODE_OPTIONS.find((option) => option.mode === generationMode);
return (
<div class="modal-backdrop" onClick={handleBackdropClick}>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="recommendation-modal-title">
{step === 'type' ? (
<>
<div class="modal-hero">
<div>
<div class="modal-eyebrow">Create Recommendation</div>
<h2 id="recommendation-modal-title">Shape the kind of discovery session you want.</h2>
<p class="modal-hero-copy">
Pick a format, choose how deep the search should go, and we&apos;ll take it from there.
</p>
</div>
<button class="modal-close" onClick={onClose} aria-label="Close" disabled={loading}>×</button>
</div>
<div class="modal-type-select">
<section class="modal-section">
<div class="modal-section-header">
<span class="modal-section-step">1</span>
<div>
<h3>Choose your format</h3>
<p>Start with the kind of thing you want to watch next.</p>
</div>
</div>
<div class="modal-type-cards">
{MEDIA_OPTIONS.map((option) => (
<button
key={option.type}
type="button"
class={`type-card${mediaType === option.type ? ' type-card--selected' : ''}`}
onClick={() => handleSelectType(option.type)}
>
<span class="type-card-title-row">
<span class="type-card-icon">{option.icon}</span>
<span class="type-card-label">{option.label}</span>
</span>
<span class="type-card-desc">{option.description}</span>
</button>
))}
</div>
</section>
</div>
</>
) : step === 'mode' ? (
<>
<div class="modal-header">
<div class="modal-header-left">
<button class="modal-back" onClick={() => setStep('type')} aria-label="Back" disabled={loading}>
</button>
<div>
<div class="modal-eyebrow">Step 2 of 3</div>
<h2 id="recommendation-modal-title">Choose the pipeline style.</h2>
</div>
</div>
<button class="modal-close" onClick={onClose} aria-label="Close" disabled={loading}>×</button>
</div>
<div class="modal-type-select">
<section class="modal-section">
<div class="modal-section-header">
<span class="modal-section-step">2</span>
<div>
<h3>Pick the search mode</h3>
<p>Go broad for variety or deeper for a more persistent search.</p>
</div>
</div>
<div class="mode-cards">
{MODE_OPTIONS.map((option) => (
<button
key={option.mode}
type="button"
class={`mode-card${generationMode === option.mode ? ' mode-card--active' : ''}`}
onClick={() => {
setGenerationMode(option.mode);
setStep('form');
}}
>
<span class="mode-card-badge">{option.badge}</span>
<span class="mode-card-label">{option.label}</span>
<span class="mode-card-desc">{option.description}</span>
</button>
))}
</div>
</section>
<div class="modal-type-footer">
<div class="modal-selection-summary">
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV Shows'}</span>
<span class="summary-pill">{selectedMode?.label}</span>
</div>
</div>
</div>
</>
) : (
<>
<div class="modal-header">
<div class="modal-header-left">
<button class="modal-back" onClick={() => setStep('mode')} aria-label="Back" disabled={loading}>
</button>
<div>
<div class="modal-eyebrow">Step 3 of 3</div>
<h2 id="recommendation-modal-title">Tell us what a great match looks like.</h2>
</div>
</div>
<button class="modal-close" onClick={onClose} aria-label="Close" disabled={loading}>×</button>
</div>
<form class="modal-form" onSubmit={handleSubmit}>
<div class="modal-summary-strip">
<span class="summary-pill">{mediaType === 'movie' ? 'Movies' : 'TV Shows'}</span>
<span class="summary-pill summary-pill--accent">{selectedMode?.label}</span>
<span class="summary-caption">
{generationMode === 'brainstorm'
? 'Fast wide search with ranking and curation.'
: 'Longer chained search that keeps exploring in batches.'}
</span>
</div>
{generationMode === 'brainstorm' && (
<div class="form-group form-group--full">
<label for="main-prompt">What are you looking for?</label>
<textarea
id="main-prompt"
class="form-textarea form-textarea--hero"
placeholder="Describe the mood, themes, setting, pacing, or vibe you want right now."
value={mainPrompt}
onInput={(e) => setMainPrompt((e.target as HTMLTextAreaElement).value)}
rows={5}
required
/>
</div>
)}
<div class="modal-form-grid">
<div class="form-group">
<label for="liked-shows">{mediaLabel}s you liked</label>
<input
id="liked-shows"
type="text"
class="form-input"
placeholder={mediaType === 'movie' ? 'e.g. Inception, The Godfather' : 'e.g. Breaking Bad, The Wire'}
value={likedShows}
onInput={(e) => setLikedShows((e.target as HTMLInputElement).value)}
required
/>
<span class="form-help">A few strong examples help the pipeline lock onto your taste.</span>
</div>
<div class="form-group">
<label for="disliked-shows">{mediaLabel}s you disliked</label>
<input
id="disliked-shows"
type="text"
class="form-input"
placeholder={mediaType === 'movie' ? 'e.g. Transformers' : 'e.g. Game of Thrones'}
value={dislikedShows}
onInput={(e) => setDislikedShows((e.target as HTMLInputElement).value)}
/>
<span class="form-help">Optional, but useful when you want to steer away from common misses.</span>
</div>
<div class={`form-group${generationMode === 'continuous' ? '' : ' form-group--full'}`}>
<label for="themes">Themes, moods, and preferences</label>
<input
id="themes"
type="text"
class="form-input"
placeholder="e.g. tense, grounded sci-fi, emotional, political intrigue"
value={themes}
onInput={(e) => setThemes((e.target as HTMLInputElement).value)}
/>
</div>
{generationMode === 'continuous' && (
<>
<div class="form-group">
<label for="requirements">Hard requirements</label>
<input
id="requirements"
type="text"
class="form-input"
placeholder="e.g. 2020+, under 2 hours, streaming on Netflix"
value={requirements}
onInput={(e) => setRequirements((e.target as HTMLInputElement).value)}
/>
</div>
<div class="form-group">
<label for="avoid">Avoid</label>
<input
id="avoid"
type="text"
class="form-input"
placeholder="e.g. gore, bleak endings, broad comedy"
value={avoid}
onInput={(e) => setAvoid((e.target as HTMLInputElement).value)}
/>
</div>
</>
)}
</div>
<div class="settings-card">
<div class="settings-card-header">
<h3>Tuning</h3>
<p>Adjust depth, validation, and how aggressively the search expands.</p>
</div>
{generationMode === 'continuous' ? (
<div class="slider-card">
<div class="slider-card-header">
<div>
<span class="slider-label">Total recommendations</span>
<span class="slider-copy">Generated in batches of 10 through the continuous pipeline.</span>
</div>
<span class="slider-value">{totalCount}</span>
</div>
<input
id="total-count"
type="range"
class="form-input"
min={10}
max={100}
step={10}
value={totalCount}
onInput={(e) => setTotalCount(Number((e.target as HTMLInputElement).value))}
/>
</div>
) : (
<div class="slider-card">
<div class="slider-card-header">
<div>
<span class="slider-label">Brainstorm pool size</span>
<span class="slider-copy">Higher values search wider before ranking and curation.</span>
</div>
<span class="slider-value">{brainstormCount}</span>
</div>
<input
id="brainstorm-count"
type="range"
class="form-input"
min={50}
max={200}
step={10}
value={brainstormCount}
onInput={(e) => setBrainstormCount(Number((e.target as HTMLInputElement).value))}
/>
</div>
)}
<div class="toggle-stack">
<div class="toggle-card">
<label class="toggle-label" for="web-search">
<div class="toggle-text">
<span class="toggle-title">Use Web Search</span>
<span class="toggle-desc">Pull in live web context for fresher, more grounded {mediaPluralLabel}.</span>
</div>
<button
id="web-search"
type="button"
class={`toggle-switch${useWebSearch ? ' on' : ''}`}
aria-pressed={useWebSearch}
onClick={handleWebSearchToggle}
>
<div class="toggle-knob" />
</button>
</label>
</div>
{generationMode === 'brainstorm' ? (
<>
<div class={`toggle-card${!useWebSearch ? ' toggle-card--disabled' : ''}`}>
<label class="toggle-label">
<div class="toggle-text">
<span class="toggle-title">Validator Agent</span>
<span class="toggle-desc">
Verify brainstormed candidates against real metadata before ranking.
</span>
</div>
<button
type="button"
class={`toggle-switch${useValidator ? ' on' : ''}${!useWebSearch ? ' toggle-switch-disabled' : ''}`}
aria-pressed={useValidator}
onClick={() => useWebSearch && setUseValidator((value) => !value)}
>
<div class="toggle-knob" />
</button>
</label>
</div>
<div class="toggle-card">
<label class="toggle-label">
<div class="toggle-text">
<span class="toggle-title">Hard Requirements</span>
<span class="toggle-desc">Bias ranking toward strict fit instead of softer exploratory matches.</span>
</div>
<button
type="button"
class={`toggle-switch${useHardRequirements ? ' on' : ''}`}
aria-pressed={useHardRequirements}
onClick={() => setUseHardRequirements((value) => !value)}
>
<div class="toggle-knob" />
</button>
</label>
</div>
<div class="toggle-card">
<label class="toggle-label">
<div class="toggle-text">
<span class="toggle-title">Self Expansive Search</span>
<span class="toggle-desc">Run extra passes using earlier full matches as context.</span>
</div>
<button
type="button"
class={`toggle-switch${selfExpansive ? ' on' : ''}`}
aria-pressed={selfExpansive}
onClick={() => setSelfExpansive((value) => !value)}
>
<div class="toggle-knob" />
</button>
</label>
</div>
</>
) : (
<div class={`toggle-card${!useWebSearch ? ' toggle-card--disabled' : ''}`}>
<label class="toggle-label">
<div class="toggle-text">
<span class="toggle-title">Validate Results</span>
<span class="toggle-desc">Verify continuous batches against real metadata before final ranking.</span>
</div>
<button
type="button"
class={`toggle-switch${validateResults ? ' on' : ''}${!useWebSearch ? ' toggle-switch-disabled' : ''}`}
aria-pressed={validateResults}
onClick={() => useWebSearch && setValidateResults((value) => !value)}
>
<div class="toggle-knob" />
</button>
</label>
</div>
)}
</div>
{generationMode === 'brainstorm' && selfExpansive && (
<div class="expansive-options">
<div class="slider-card slider-card--compact">
<div class="slider-card-header">
<div>
<span class="slider-label">Extra passes</span>
<span class="slider-copy">How many additional exploration rounds to run.</span>
</div>
<span class="slider-value">{expansivePasses}</span>
</div>
<input
type="range"
class="form-input"
min={1}
max={5}
step={1}
value={expansivePasses}
onInput={(e) => setExpansivePasses(Number((e.target as HTMLInputElement).value))}
/>
</div>
<div class="segmented-control">
<button
type="button"
class={`segmented-control-btn${expansiveMode === 'soft' ? ' segmented-control-btn--active' : ''}`}
onClick={() => setExpansiveMode('soft')}
>
Soft
</button>
<button
type="button"
class={`segmented-control-btn${expansiveMode === 'extreme' ? ' segmented-control-btn--active' : ''}`}
onClick={() => setExpansiveMode('extreme')}
>
Extreme
</button>
</div>
</div>
)}
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onClick={onClose} disabled={loading}>
Cancel
</button>
<button type="submit" class="btn-primary" disabled={loading}>
{loading ? 'Creating…' : generationMode === 'brainstorm' ? 'Start Recommendation' : 'Start Continuous Search'}
</button>
</div>
</form>
</>
)}
</div>
</div>
);
}